MCP authorization, with Authentik as the identity provider
If you’re wiring an MCP server up to Authentik and stuck on how a client is supposed to get a token, this is for you. We hit the same wall, and the short version is: Authentik cannot be the OAuth authorization server for your MCP clients, and no amount of configuration fixes that. It has to be someone else — plausibly your own resource server. Here’s how we got there, including the wrong turn we took first.
The problem, precisely
MCP’s authorization spec builds on OAuth 2.1. Your resource server (the thing serving POST /mcp) points clients at an authorization server, and the client gets a token from it before it’s allowed to call a tool. Nothing unusual so far — except MCP clients are typically dynamic. Claude Code doesn’t ship with a pre-registered client_id for your server; it expects to either:
- register itself on the fly via RFC 7591 Dynamic Client Registration (
POST /oauth/register), or - present a Client ID Metadata Document (CIMD) — an
client_idthat is itself anhttps://URL the authorization server fetches to learn who’s asking.
Authentik supports neither. There’s no registration_endpoint in its discovery document, POST /register doesn’t exist, and CIMD support isn’t there either. This isn’t a config flag you’re missing — goauthentik#8751 has been open since February 2024, milestoned, unshipped. Every OAuth client Authentik can talk to has to be created by hand in its admin UI.
That’s fine for your web app’s login — you register assistant-web once and move on. It’s fatal for MCP, because you don’t control the client. Claude Code tries dynamic registration, finds nothing, and gives up with “Incompatible auth server: does not support dynamic client registration.” There’s a claude mcp add --client-id escape hatch for pre-registered clients, but it’s a dead end too — claude-code#38102, closed as not planned, reports the flag being silently ignored. And even if it worked, “manually register a client per person who wants to connect” is the exact toil DCR exists to avoid.
So: pointing MCP clients at Authentik for a token is a dead end. Something else has to be the authorization server.
The shortcut that looks free, and isn’t
Before building a real authorization server, we considered the cheap version: a thin shim that answers discovery and registration requests itself, but hands off the actual authorization_endpoint and token_endpoint to Authentik. Every client that registers gets mapped onto one pre-provisioned Authentik client under the hood. No new state machine, no token storage, just a translation layer.
It doesn’t survive contact with a conformant client, for a specific and checkable reason. The MCP authorization spec has clients record the issuer from the metadata document they validated, and compare it against the RFC 9207 iss parameter attached to the callback. If your metadata document advertises your own issuer, but Authentik is the one that actually answers the authorization request and stamps the callback, the two iss values never match. Every conformant client rejects the response as a possible mix-up attack; a client that doesn’t check iss accepts it, silently, which is worse.
Fixing that would mean Authentik answering as your issuer — which it can’t — or forwarding requests through in a way that makes the shim not “thin” anymore: a redirect-URI allowlist covering both claude.ai and arbitrary loopback ports, a property mapping to fake an audience per client, and one shared client_id standing in for every distinct caller. At that point you’ve built an authorization server with extra steps and none of the guarantees.
The actual fix: be your own authorization server
The resource server — the thing serving /mcp — becomes the OAuth 2.1 authorization server for its own resource. Authentik keeps doing exactly the one thing it’s good at and nothing more: authenticating the human. It gains no new client, no new provider, no new configuration. If you already run an OIDC backend-for-frontend in front of Authentik for your web login, this slots in beside it rather than replacing anything.
Concretely, that means your API now serves:
GET /.well-known/oauth-authorization-server(RFC 8414 metadata)GET /.well-known/oauth-protected-resource(RFC 9728, unauthenticated by design — it’s how a client with no token yet finds out where to go)POST /oauth/register(RFC 7591 DCR) and CIMD supportGET /oauth/authorize,POST /oauth/token,POST /oauth/revoke(RFC 7009)- a consent screen, somewhere your frontend owns
The handoff to Authentik happens at exactly one point: GET /oauth/authorize with no existing session redirects into your ordinary login flow — Authentik does its OIDC dance, your callback handler does its usual thing — and then resumes the authorization request it was in the middle of. Authentik never hears the word “MCP.” As far as it’s concerned, a human just logged in.
One wrinkle worth knowing before you hit it: if your callback handler regenerates the session id after login (the standard defence against session fixation — and you should be doing this already), anything you stashed in the session before the login hop is gone when you come back. That’s why the “resume” step can’t rely on session state. Persist the in-flight authorization request as a row — an id round-trips through the login detour just fine even though the session object underneath it gets replaced.
The pieces that actually matter
You don’t need to reimplement all of OAuth from scratch to get this right, but a few decisions are load-bearing and worth making deliberately rather than defaulting into:
Public clients only, PKCE mandatory, no client secret. Every MCP client is either a loopback native app or a browser app; neither can keep a secret, so don’t pretend otherwise by adding a client_secret column you’ll never populate safely. OAuth 2.1 makes PKCE (S256) mandatory for exactly this reason — it’s your actual protection, not the secret you don’t have.
Opaque tokens, hashed, not JWTs. The usual case for signed JWTs is avoiding a database round-trip per request. That case evaporates here: you already need one lookup to turn a token’s subject into a local user, so with your own tokens that same lookup is the verification. What you get back for giving up the “stateless” property is revocation that actually means something — disconnecting a client is an UPDATE, not a denylist — and no JWKS endpoint to stand up, rotate, or back up. Store tokens as SHA-256 digests, never plaintext, so a database dump isn’t a bag of live credentials.
RFC 8707 resource bound into the grant, not just checked once. Validate it on the authorization request, freeze it into the code, bind it into the grant. This is what makes “this token was minted for this MCP server” a fact your resource server can check locally, rather than something it has to trust the identity provider got right — which matters a lot once you’re the one issuing the token in the first place.
A real consent screen, because DCR is unauthenticated by specification. Nothing stops an attacker from registering a client that looks legitimate and points its redirect URI at their own server. The thing that separates a client your user actually wants connected from one an attacker created is a human looking at a screen that says this client, this destination and clicking allow. Don’t skip this because registration already “succeeded” — registration creating a row and a human approving it are different events, and only the second one should unlock a token. Two things are worth not rendering on that screen: a client-supplied logo_uri (an unauthenticated registrant can point it at anything, including content that makes an attacker’s client look like a trusted one), and any of state, code_challenge, or resource — the screen has no use for them, and a value that never reaches the browser can’t be tampered with there.
Loopback redirect-URI matching has to ignore the port, but only there. CLI-style MCP clients bind an ephemeral loopback port every run, and a strict string match will accept exactly one session and reject every one after. OAuth 2.1 §8.4.2 and RFC 8252 §7.3 carve out 127.0.0.1, localhost, and [::1] specifically for this. Scope the exemption tightly — http only, those hosts only, and only the port — because http://127.0.0.1.evil.example/callback is not loopback, and an https redirect never gets this relaxation at all.
Two situations should revoke, not just refuse. A replayed authorization code and a reused (already-rotated) refresh token are both ambiguous: maybe your own client retried after losing a response, maybe someone has a stolen copy. OAuth 2.1 §4.1.3 tells you to assume the worst — kill the whole grant, not just the one bad request. Refusing the replay while leaving the original tokens alive means an attacker who won the race still has something that works.
What we didn’t build, on purpose
Not every gap is worth closing immediately, and it’s worth being explicit about which ones you’re choosing to leave open rather than discovering it later:
- Granular scopes. One scope is enough when authorization is identity-based — a valid token says who you are, and your existing ownership/visibility rules decide what that allows. Scope-gated tools are future work, not a blocker.
client_credentials. There’s no “client” here without a human behind it; every flow is a person authorizing an app on their own behalf.- RFC 7592 client management. Optional, and one more near-unauthenticated surface you’d have to defend.
- Forced re-authentication on every
/oauth/authorize. Requiring a fresh login every time someone connects a client trades a small security increment for a real amount of friction, and the predictable response to friction is people choosing longer-lived tokens instead — which is worse. Per-client consent is what actually defends against a malicious client; it doesn’t need session age to do that job.
A checklist for your own Authentik instance
If you’re reproducing this shape:
- Leave Authentik alone. You register one redirect URI there — your app’s normal OIDC callback — and nothing MCP-specific ever gets configured at the IdP. If you find yourself adding an MCP-flavoured client, property mapping, or scope to Authentik, you’ve gone down the shim path above; back out.
- Your resource server’s canonical URI does double duty: it’s the value clients pass as
resource(RFC 8707), it’s what you bind into every grant, and its origin is your new authorization server’s issuer identifier. Derive the issuer from that URI rather than configuring it separately — one source for both, so they can’t drift apart. - Verify the two discovery documents first, before you try a real client:
/.well-known/oauth-protected-resourceand/.well-known/oauth-authorization-server. A 404 on either, in production, usually means a reverse proxy isn’t routing those paths to your API — it’s indistinguishable from “no authorization server exists” to the client, so it’s the first thing to rule out. - Call
/mcpwith no token and check the401. TheWWW-Authenticateheader should name the protected-resource metadata document and a scope — that header is the discovery mechanism, and a client that can’t find it never gets any further. - Only then try a real client end to end: register (or present a CIMD), authorize, land on your consent screen, approve, and confirm the redirect carries a code your token endpoint accepts. If step 4 works but this doesn’t, the break is almost always in redirect-URI matching or PKCE verification, not in Authentik.
Authentik is still doing real work in this design — it’s the thing that knows who your users are, and nothing above changes that. It just isn’t, and structurally can’t be, the party that decides what an MCP client is allowed to do on a user’s behalf. Once that split clicks, the rest is a fairly ordinary OAuth 2.1 authorization server, sized to the one resource it exists to protect.