Skip to main content

Your IdP Doesn't Speak MCP Auth Yet: The Shim, the iss Hack, and the Trust Math

11 min readDora NodaDora Noda
Share
On this page

Your Keycloak login works. The user authenticates, approves the consent screen, and gets redirected back — and then the MCP client throws the whole flow away. No token request, no retry, no error your IdP ever logs, because from your IdP's point of view nothing failed. The client simply looked at the authorization response, found the issuer label missing or wrong, and refused to redeem the code. That silent walkaway is the signature failure of the MCP 2026-07-28 authorization profile meeting an identity provider that has never heard of it.

The gap is not a bug in your IdP. It is a dialect mismatch: the spec now requires behaviors no stock OAuth server emits, and the fastest correct answer is a small shim that speaks MCP on one side and plain OAuth on the other. This post is about that shim — what it rewrites, what it provably cannot rewrite, and whether it is scaffolding or permanent edge infrastructure for a deploy-authority MCP server.

The rejection your IdP can't explain​

Under the 2026-07-28 MCP Authorization specification, the client records the authorization server's issuer from discovery metadata before the flow starts, then MUST validate the iss parameter on the authorization response against that recorded value — byte for byte, no normalization — before transmitting the code to any token endpoint. The rule comes from RFC 9207, a 2022 countermeasure against authorization-server mix-up attacks, adopted into MCP via spec proposal SEP-2468. It applies to error responses too: on mismatch the client MUST NOT even act on the error text.

Three things in that profile break a stock IdP integration:

  1. The iss echo. Your IdP either omits iss or emits its own issuer — neither of which matches the issuer the client recorded, because the client discovered your MCP endpoint's authorization server entry, not your IdP directly. The client rejects a code your IdP minted in good faith.
  2. MCP-shaped client onboarding. The spec prefers Client ID Metadata Documents and keeps Dynamic Client Registration only as deprecated backwards compatibility. Most enterprise IdPs offer neither in the shape MCP clients expect — no open DCR endpoint handing out client IDs to local apps like Cursor or Claude Code.
  3. Greedy clients. MCP clients read scopes_supported from discovery and request all of them, and some (Claude Code, Cursor) unconditionally add offline_access for refresh tokens. Against an IdP with dozens of scopes or consent-gated offline tokens, the authorize request balloons or gets rejected outright.

"Just upgrade the IdP" fixes none of this. These are MCP-profile behaviors, not CVEs — your IdP vendor has no generic-OAuth reason to emit them. So the ecosystem did what ecosystems do: it put a translator in the middle.

The shim in one picture, and the line it cannot cross​

mcp-auth-adapter (started as velias/mcp-auth-adapter, now under the Red Hat community AI tools org) is a thin, stateless facade that sits in front of any OAuth 2.0 / OIDC IdP — Keycloak, Auth0, Okta, Azure AD, Google — and presents itself to MCP clients as the authorization server. Authentication and token issuance stay on the upstream IdP; the adapter issues nothing. Your MCP server announces the adapter in its RFC 9728 metadata, clients discover it via .well-known, and the IdP integration is written once instead of once per MCP client.

text
MCP client  <--MCP dialect-->  adapter  <--plain OAuth-->  upstream IdP
    |                              |                            |
    +---- discovers adapter as -----+                            |
      the authorization server       +-- proxies authorize, -----+
      (issuer = adapter URL)            token, DCR, JWKS

Here is the entire trust model in one table. Everything unsigned is shapeable; everything signed belongs to the IdP forever:

The adapter rewrites......but it cannot touch...
Discovery issuer (set to its own URL)JWT iss claim inside tokens (signed by the IdP; validate against the upstream issuer, never the adapter)
iss on the authorization response (via interception, next section)JWT aud / audience shape (whatever the IdP emits; the adapter cannot stamp its own URL into a signed token)
token_endpoint (rewritten to itself when the authorize proxy is on)Token signatures (verified against the upstream JWKS, which the adapter's discovery points at)
scopes_supported (announce only what your servers need) and scopes on authorize/PAR (allowlist or denylist)The upstream login and consent experience (still your IdP's pages)
DCR (returns a fixed public client_id, with per-client mapping)JWT-bearer aud on client assertions (the client aims at the adapter; the upstream validates against itself — see the v2.1 gotcha below)

Deployment is four environment variables:

bash
MCP_BASE_URL=https://mcp-auth.example.com
MCP_UPSTREAM_SSO_URL=https://sso.example.com/auth/realms/external
MCP_PROXY_AUTH_STATE_SECRET=$(openssl rand -hex 32)
MCP_PROXY_AUTH_ALLOWED_REDIRECT_URIS=http://localhost:*,http://127.0.0.1:*

That table is the core deliverable of this post, so here is the one-sentence version to carry into the next sections: the shim owns the envelope of the OAuth flow and the IdP owns the contents, and every operational decision below is about which side of that line a given requirement falls on.

How v2.0's iss interception actually works​

Version 2.0.0 (June 12, 2026) exists for exactly one reason: without interception, no deployment of this shape can pass a 2026-07-28 client. The client compares iss to the adapter's advertised issuer; the IdP emits its own issuer or nothing at all. Byte-for-byte comparison fails either way. Someone in the middle has to swap the label, and that someone is the authorize proxy:

  1. GET /authorize — validates the client's redirect_uri against the configured allowlist, wraps the original redirect_uri and state into an HMAC-signed state blob, swaps in the adapter's own callback URL, and redirects to the upstream IdP.
  2. GET /authorize/callback — verifies the state blob (HMAC plus expiry), validates the upstream iss (strict when the IdP advertises RFC 9207 support, validate-if-present when it doesn't), then redirects to the original client URL carrying the adapter's iss.
  3. POST /token — proxies the token request, rewriting redirect_uri back to what the upstream IdP expects, since the code was minted against the callback URL.

The release was honestly breaking: the state secret and the redirect allowlist became required, the upstream IdP client must register the callback URL, and token_endpoint in discovery now points at the adapter whenever the proxy is active. Secret rotation is zero-downtime via a previous-secret variable and a configurable state TTL (30 minutes by default).

The security properties are the ones you would demand from anything sitting in this position: the callback forwards only a whitelist of OAuth-defined parameters, sets no-store and no-referrer, never logs the authorization code, rejects redirect URIs with fragments or userinfo, and compares HMACs in constant time. One caveat is documented rather than hidden: the state blob is signed, not encrypted — it carries the redirect URI and original state, both non-secret, with confidentiality resting on TLS and the short TTL. Later releases kept hardening the same surface: v2.4.0 (July 31, 2026) added DPoP and PAR support plus SSRF hardening on outbound fetches, which is the maintenance signal you want to see in auth-adjacent infrastructure.

Headless agents get in through v2.1​

The interactive flow covers humans driving Cursor or Claude Code. Scheduled deploys and event-driven rollbacks have no human and no browser, which is what v2.1.0 (June 22, 2026) addresses with passthrough for the MCP OAuth Client Credentials extension: the token proxy forwards client_credentials and JWT-bearer grants to the upstream IdP unmodified, so a CI job can mint a token for your deploy MCP server with a secret or a signed assertion and no redirect dance. The extension recommends JWT assertions over shared secrets — secrets are long-lived credentials that silently impersonate your pipeline until rotated — and the adapter supports both, including client_secret_basic and client_secret_post.

There is one genuine gotcha, and it falls exactly on the signed/unsigned line from the table. A JWT-bearer client sets the assertion's aud to the token endpoint it discovered — the adapter's — but the upstream IdP validates aud against its own endpoint and rejects the mismatch. The documented fixes are, in order of preference: configure the upstream IdP to accept the adapter's public endpoint URLs as valid assertion audiences (in Keycloak this is a per-client setting), or configure the client to aim aud at the upstream URL directly, which couples the client to your internal topology and fights SDKs that derive aud from discovery automatically. The secrets flow and public clients are unaffected — this is purely a signed-assertion-through-a-proxy artifact, inherent to the shape, not a bug to be fixed in a later release.

The trust math, honestly​

"Sees every token exchange" is the sentence that should make you pause, so let's price it precisely. Authorization codes transit the callback; access and refresh tokens transit the /token proxy. A compromised adapter sees credentials in flight and could drive flows. Against that: it issues no tokens, holds no signing keys, stores no sessions — state lives in HMAC blobs verified on arrival, which is also why the service scales horizontally with nothing to exfiltrate at rest. The blast radius is transit visibility plus flow control, not credential minting.

The mitigations above (allowlists, TTLs, no-store, code never logged, parameter whitelisting) shrink the attack surface to the proxy logic itself, which has had an OWASP review and a public changelog of hardening fixes. The softer spot is open DCR: the adapter hands any requester a fixed public client_id, and client_name is self-asserted and spoofable, so per-client IdP mapping buys operational isolation (separate consent screens, audit trails, revocation) rather than authentication. The real control is the per-client redirect-URI allowlist enforced at authorize time — even a spoofed name can only redirect where the operator allowed — with user consent at the IdP as the last line of defense, a limitation the MCP spec itself acknowledges for local-app redirect flows.

Framed as a decision: the shim doesn't reduce the number of parties you trust; it changes which party sees the plaintext of the handshake. Running it on your own fleet next to the IdP is a lateral move — one more workload you operate, beside the one you already do. Consuming it as someone else's hosted service is a delegation of the most sensitive transit in the system. For a deploy-authority server, self-host the shim or don't use one.

Scaffolding or permanent edge?​

The TODO-shaped question — adapter-today-native-tomorrow, or permanent edge like a gateway's OAuth plugin — has a conditional answer. Go permanent when your IdP vendor has no MCP profile on its roadmap, you serve several MCP clients with divergent behaviors (the known-client table the adapter maintains is a living argument for centralizing this mess in one place), and you already operate edge auth infrastructure. Treat it as a bridge when your IdP has committed to native iss plus MCP-shaped onboarding, you serve exactly one client, or policy forbids credentials transiting a middlebox at all.

Two signals decide when to re-evaluate. First, client adoption of Client ID Metadata Documents: the spec prefers CIMD and DCR is on a deprecation clock, so the adapter's DCR-and-mapping machinery should shrink over time, not grow. Second, maintenance momentum — and here the news is good: the move to the redhat-community-ai-tools org (note that container pulls must move to ghcr.io/redhat-community-ai-tools/mcp-auth-adapter, since GHCR doesn't redirect the old velias namespace) plus the v2.4 hardening work says "maintained edge component," not "one person's weekend project."

The verdict for a deploy MCP server on owned infrastructure: deploy it as edge infrastructure with an exit plan. Pin the version, own the allowlists, and revisit only when your IdP speaks iss natively AND your clients speak CIMD. Until both are true, "temporary" is just a word for running this gap unsupported.

Monday morning: the 5-step adoption checklist​

  1. Register the upstream client. Create one IdP client per MCP application (or one shared client to start) with the adapter's callback URL as an allowed redirect. This is the only change your IdP team has to make.
  2. Deploy the pinned adapter. Pull the new-org image by digest, set the four variables above, and add a per-client redirect map so Cursor, Claude Code, and VS Code each get only their own redirect patterns.
  3. Wire the server side. Point your MCP server's RFC 9728 metadata at the adapter — then validate token iss against the upstream issuer and aud against what the IdP actually emits, with signatures via the adapter-advertised JWKS. (The server-side half of this migration is the six-item 2026-07-28 auth fix checklist this blog published in September; this post is its client-side companion.)
  4. Tame greedy clients. Announce a minimal scopes_supported and strip offline_access at the proxy if your policy forbids refresh tokens — demand-side and supply-side controls together.
  5. Light up headless access. Enable client credentials for the CI deploy agent with the IdP-side audience fix, scope the credential to deploy tools only, and put secret rotation on a calendar.

Do those five and the silent walkaway from the opening paragraph becomes a login that completes: the client sees the issuer it expects, the IdP sees the OAuth it already speaks, and the translation between them is one stateless container you own.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with agent-operated deploy tooling that has to clear the same auth bar this post describes. Star the repo on GitHub or deploy your first app today.

Related articles

Give your agents a chain backend

Autonomous agents hit RPC endpoints very differently than people do. See what bex router handles on their behalf.

Read the agents guide