Skip to content
Hack Reports/LAB
Post

Keycloak was the correct answer. I picked Auth0 anyway.

OAuth in front of a self-hosted MCP gateway for Claude.ai, with the fewest moving parts I could get away with. The IdP shortlist, the three defaults that broke, and the conditions under which I'd reverse the call.

I run infrastructure the boring way: Docker Compose behind Caddy, Tailscale for anything that should not face the internet, Postgres for state. The bias is self-hosted, open-source, minimum vendor surface. So when my MCP gateway needed OAuth in front of it, the obvious answer was Keycloak. It is open source, it does everything, and it is the default recommendation in every self-hosted identity thread.

I picked Auth0 instead. This post is the math.

The setup

The gateway is ContextForge (mcp-contextforge-gateway 1.0.7), a federation gateway that fronts multiple MCP servers behind a single endpoint. Instead of exposing each server individually — Google Workspace tooling, a CMS, internal services — they register with the gateway and clients see one URL. It runs in Compose behind Caddy, with Tailscale carrying everything that is not the public MCP endpoint.

The client that matters is Claude.ai. Custom connectors require a remote server, and the moment tools touch private data they require real authentication. So the gateway has to speak the MCP authorization spec, and I need an authorization server. That is the whole problem: one gateway, N servers behind it, and an identity layer that should add as close to zero operational weight as possible.

What the Claude connector flow actually requires

Worth being precise here, because this is where most of the pain lives. When Claude.ai connects to a protected MCP server, the sequence is roughly:

  1. Claude calls the MCP endpoint and gets a 401 with a WWW-Authenticate header pointing at protected resource metadata (RFC 9728).
  2. It fetches /.well-known/oauth-protected-resource, which names the authorization server.
  3. It pulls the authorization server metadata and registers itself as a client via Dynamic Client Registration (RFC 7591). There is no paste-your-client-ID step.
  4. Authorization code flow with PKCE. The user logs in, Claude gets an access token.
  5. Claude presents that token on every request. The gateway validates it and maps it to a user.

So the IdP shortlist has a hard filter before any philosophy enters the picture: standards-compliant AS metadata, working DCR, and tokens the gateway can validate offline against a JWKS. That filter kills more candidates than you would expect.

The candidates

Keycloak. Passes every requirement. It is also a JVM service with its own database, its own upgrade treadmill, realm export discipline, and a CVE feed you are now personally subscribed to. An identity provider is the worst possible service to run casually: security-critical, stateful, and invisible until it breaks. For a fleet of exactly one gateway, I would be operating a second production system to authenticate the first.

Authelia. Excellent at the job it was built for — forward-auth in front of a reverse proxy — and I would still reach for it there. Its OIDC provider side did not cover what this flow needs. [G: your specific finding here — what was missing when you evaluated it, e.g. the DCR surface.]

Pocket ID. The aesthetic winner: single container, tiny footprint, passkey-first. Exactly what a self-hoster wants an IdP to look like. [G: your dealbreaker here — the RFC 9728 / DCR gap as of your evaluation.]

Auth0. A managed service from the company I would normally route around. The free tier currently covers 25,000 monthly active users, the standards surface — discovery metadata, DCR, PKCE, JWKS — exists out of the box, and it adds zero containers to my stack. The real cost is configuration archaeology: the defaults are wrong for MCP in three specific ways, and finding that out took longer than the setup itself.

Under a fewest-moving-parts constraint the scoreboard is not close. Keycloak adds two stateful services to protect one. Auth0 adds none. The philosophical discomfort is real; the operational argument is not.

The three defaults that broke

1. Dynamic Client Registration is off

Claude's connect flow has no client-ID field. It registers itself. Auth0 supports this, but ships with it disabled: Dashboard → Settings → Advanced → enable OIDC Dynamic Application Registration. And because dynamically registered clients are third-party applications in Auth0's model, your login connections have to be promoted to domain level or the login screen renders with no identity providers on it. The promotion is a Management API call, not a dashboard toggle. [G: paste the exact PATCH call you used on the connection.]

2. No audience means opaque tokens

The MCP spec has clients send a resource parameter (RFC 8707). Auth0 does not mint a JWT off that — it wants its own audience parameter, and when it does not receive one it issues an opaque token. The gateway cannot validate an opaque token against a JWKS, so every request bounces with a 401 even though login succeeded. [G: the exact log line from the gateway here.] The fix is a tenant-level Default Audience pointing at an API identifier, so token requests without an explicit audience still produce a verifiable JWT. It is tenant-wide, which is fine for a single-gateway tenant and worth a pause if the tenant serves anything else.

3. The token has no identity in it

This was the slow one. Everything connected, and user mapping failed anyway. ContextForge resolves the authenticated user from token claims, and an Auth0 access token carries sub (auth0|...) but no email — access tokens are not ID tokens, and Auth0 silently strips custom claims that are not namespaced as URIs. The fix is a post-login Action that injects a namespaced claim into the access token, and pointing the gateway's claim mapping at it. [G: your final Action snippet and the ContextForge claim-mapping config.]

What I run now

Caddy, Compose, Tailscale, ContextForge. Zero additional containers for auth. On the Auth0 side: one tenant with a default audience, DCR enabled, connections promoted, one post-login Action. Claude.ai connects with a URL and a login screen, and the per-server credentials never leave the gateway.

Reversal conditions

This is a decision with an expiry date, not an endorsement. I move back to self-hosted — probably Keycloak, possibly whatever Pocket ID ships next — if any of these trip:

  • The MAU cap starts to matter. Auth0's limits are hard stops, not overage billing: exceed the plan and authentication is disabled. Fine for a personal gateway, disqualifying for anything customer-facing.
  • I need control of token shape or lifetimes beyond what Actions allow.
  • Product work needs per-customer IdPs or real multi-tenant isolation, which changes the calculus entirely.
  • The third-party-app and consent quirks stop being three settings and become an ongoing fight.
  • The MCP auth spec shifts to something Auth0 lags on. The spec is young and moving; convenience lock-in flips to liability fast.

Datestamp

Written August 2026 against mcp-contextforge-gateway 1.0.7, the MCP authorization spec as of that date, and Auth0's free tier at 25,000 MAUs. All three of those will change. If you are reading this in 2027, re-verify everything before copying anything.


All posts