Skip to main content
avatarJay Patel.

Authenticating MCP Clients to a Remote Server: Why DCR Fails on Cognito, and What Works Instead

The MCP spec says a client should discover the auth server and dynamically register itself. Cognito supports neither half of that. This is the war story of getting Kiro, Claude, claude.ai, and VS Code Copilot all authenticated to one remote MCP server behind Cognito — the DCR dead end, the static client-ID path that works, and the per-client quirks that only surface live.

Jul 20, 202612 min readBackend, AI

The Problem: A Remote MCP Server Needs a Login

A local MCP server is easy: the host spawns the process and talks to it over stdin/stdout. There's no network, so there's no auth. But the moment your MCP server is remote — a URL an agent connects to over HTTP — you have the whole authentication problem back, and MCP servers are a peculiar shape of it. The client isn't a browser you control; it's Claude Desktop, or claude.ai, or VS Code's Copilot, or Kiro — each an OAuth client you didn't write, that has to obtain a token and present it to a server it discovered from a URL.

I had exactly this: a remote MCP server deployed on Bedrock AgentCore, fronted by an authorizer that validates a JWT, with Amazon Cognito as the identity provider. The task sounded routine — "let an MCP client log in and call it." It was not routine. What follows is the design that works, and the two dead ends I walked all the way down first, because the dead ends are where the actual lessons are.

Enter the Spec's Answer: Discovery + Dynamic Client Registration

The MCP authorization spec has a clean, ambitious answer for "how does a client I didn't write get a token." The client is supposed to:

  1. Fetch the server's protected-resource metadata (a .well-known document) and follow its pointer to the authorization server.
  2. Dynamically Register itself with that auth server (RFC 7591, DCR) — hit a registration_endpoint, get back a fresh client_id, no human in the loop.
  3. Run authorization-code + PKCE against the auth server, get a token whose aud (audience) claim names the MCP server, and present it.

It's genuinely elegant: any client, any server, zero pre-coordination. The AWS reference material for this pattern uses Auth0, which supports it fully — Auth0 exposes a registration_endpoint, and it stamps the MCP URL into the token's aud. Follow the notebook, and it just works.

Then I swapped Auth0 for Cognito, and both halves of the mechanism fell out from under me.

The Wall: Cognito Supports Neither Half of DCR

Two facts, each verified the hard way against a live pool:

Cognito has no registration_endpoint. A client that insists on DCR fetches Cognito's OIDC discovery document, finds no registration endpoint, and dies with "Incompatible auth server: does not support dynamic client registration." There is no dynamic registration on Cognito, full stop.

Cognito access tokens have no aud claim. So even if you got a client registered somehow, the server cannot validate on audience — the field the spec expects to check simply isn't in the token. You have to validate on something else. Cognito access tokens carry client_id, so the authorizer validates on that instead: an allowedClients list of pre-approved client IDs. Which, of course, is the exact opposite of dynamic registration — you're back to a static allow-list.

So the spec's happy path is closed on Cognito at both ends. The reference notebook never warns you, because the reference uses Auth0. This is the kind of thing you only discover by deploying it.

Dead End #1: Proxy the DCR Endpoint

The obvious fix: if Cognito lacks a registration_endpoint, put one in front of it. Stand up a small proxy (a Lambda behind API Gateway) that implements registration_endpoint, auto-whitelists whatever registers, and forwards everything else to Cognito. Give the MCP server that proxy's URL as its discovery endpoint, and clients think they're talking to a DCR-capable auth server.

I built it. It doesn't work, for a reason that took a throwaway probe to nail down:

AgentCore's protected-resource metadata advertises whatever issuer the discovery document declares — not the host that served it.

You cannot stitch a discovery document on a proxy to make the platform advertise a DCR-capable auth server while keeping Cognito as the actual token issuer. Point the server's discoveryUrl at your proxy, and the metadata it hands clients still resolves back to Cognito's issuer — because that's what the discovery doc says, regardless of who served it. The proxy can intercept registration, but it can't rewrite the identity of the issuer the client ultimately trusts. The whole discovery-chain approach is a mirage on Cognito.

The DCR proxy — Lambda, API Gateway, the auto-whitelist logic — got torn down. Its IaC is disabled in the repo as a headstone.

Dead End #2: An Auth-Server Metadata Shim

The second attempt was subtler. If the problem is the metadata a client reads, maybe you shim just the metadata — serve a hand-crafted auth-server-metadata document that points registration and token endpoints wherever they need to go, and let the client follow it.

This one I didn't even finish before a live test killed it. When I actually watched what VS Code did with a remote MCP server, the "shim" turned out to be solving a problem that wasn't there: with the config correct, the client read the real protected-resource metadata, followed its authorization_servers pointer straight to Cognito's hosted UI, and logged in. The metadata shim was a fix for a failure mode that only existed because of a different, dumber bug (below). Removing the shim and fixing the real bug worked; keeping the shim just added moving parts.

The lesson from both dead ends is the same: don't build infrastructure to satisfy the spec's ideal path when the identity provider structurally can't support it. Find the path the provider does support and take it.

What Actually Works: The Static Client-ID Path

The working design abandons dynamic registration entirely and leans on the one thing Cognito does well:

  • Pre-register one public app client in Cognito — public meaning no secret, authorization-code + PKCE only.
  • Put that client's ID in the server's allowedClients list (and set no required audience, since there's no aud to check).
  • In each MCP host's config, supply that client ID as oauth.clientId. This makes the client skip DCR — it has a client ID already, so it doesn't try to register one — and run authorization-code + PKCE directly against Cognito's hosted UI.
  • The user logs in once; the client manages token refresh. The resulting access token carries client_id = <the static client>, the authorizer sees that ID in allowedClients, and the call is accepted.

No proxy, no token minting, no per-client registration. One shared public client that every host points at. Here's the shape of a host config (a claude.ai-style remote MCP connector):

{
  "mcpServers": {
    "my-registry": {
      "type": "http",
      "url": "https://bedrock-agentcore.<REGION>.amazonaws.com/registry/<REGISTRY_ID>/mcp",
      "oauth": {
        "clientId": "<STATIC_PUBLIC_CLIENT_ID>"   // ← the whole trick: skips DCR
      },
      "oauthScopes": ["openid", "email", "profile", "https://<RESOURCE>/<scope>"]
    }
  }
}

That oauth.clientId field is doing all the work. Without it, a spec-compliant host tries DCR and dies on Cognito. With it, the host skips straight to the flow Cognito supports.

If you ever need real DCR — a host that refuses to accept a static client ID — the only honest escape hatch is a full OAuth proxy that fronts Cognito with its own registration_endpoint and issues its own tokens as the issuer (not just proxies the metadata). That's a real auth server you now operate, with all the responsibility that implies. For a portfolio project or an internal tool, the static client is overwhelmingly the right call.

The Real Work: One Server, Four Clients, Four Different Gauntlets

Here's the part nobody tells you: getting the server auth right is maybe half the battle. Each MCP host is its own OAuth client with its own quirks, and the same correctly-configured server needs per-client accommodations. I got four hosts working — Kiro, Claude Desktop, claude.ai, and VS Code Copilot — and every one had a surprise.

The URL trailing slash breaks discovery (VS Code). With the MCP url ending in /mcp/ (trailing slash), VS Code failed auth-server discovery and fell back to guessing the authorize endpoint from the MCP host — it sent the browser to https://bedrock-agentcore.<REGION>.amazonaws.com/authorize, which is a load balancer that returns 403 Forbidden. No login screen, ever. Drop the trailing slash (/mcp), and VS Code correctly reads the protected-resource metadata, follows it to Cognito, and opens the real hosted UI. A single character.

Cognito rejects the entire login over one ungranted scope (VS Code). VS Code derives the scopes it requests from the auth server's advertised scopes_supported. Cognito advertises ["openid","email","phone","profile"] at the user-pool level, so VS Code requests all four — including phone. If the app client doesn't allow phone, Cognito rejects the whole /authorize request with invalid_scope — it rejects on any ungranted scope, not just the ones that matter. Kiro and Claude never hit this because they request an explicit, narrower scope list that omits phone. The fix is to grant phone on the client (it's inert — the test user has no phone — and a wider allow-list never breaks a client that asks for less).

The OAuth callback must match Cognito byte-for-byte, and clients disagree on what it is. Cognito matches redirect_uri exactly and supports no wildcard ports. But every host constructs its callback differently:

http://localhost:8766/oauth/callback      # Kiro:   host:port from config, appends /oauth/callback, uses "localhost"
http://localhost:40806/callback           # Claude Code
https://claude.ai/api/mcp/auth_callback   # claude.ai web connector: a hosted relay
http://127.0.0.1:33418/                    # VS Code: "127.0.0.1" (not localhost!), trailing slash, default port
https://vscode.dev/redirect                # VS Code: also a hosted relay

Kiro takes a host:port and appends /oauth/callback with host localhost. VS Code uses 127.0.0.1 with a trailing slash and a default port that, if busy, silently becomes a random port — which then fails Cognito's exact match with redirect_mismatch. The only reliable move: read the actual redirect_uri param out of the /authorize URL the client opened, and register that exact string.

Clients cache the auth provider they built (VS Code). After any config change, VS Code keeps reusing the old, wrong dynamic auth provider — so a correct config still looks broken. You have to explicitly Authentication: Remove Dynamic Authentication Providers and restart the server. This one cost me an embarrassing amount of time chasing a bug I'd already fixed.

None of these are in any spec. They're what "any client, any server, zero pre-coordination" actually costs in practice.

Two Directions of Trust: Inbound vs. Outbound

Everything above is inbound auth — a user, through a host, proving to the server who they are. There's a second direction that's easy to conflate and shouldn't be: outbound auth, when the platform's own components talk to each other.

In a setup where a Gateway fronts a Runtime (which is how I deployed an interactive Kanban MCP App), the Gateway is a trust boundary. A user's identity stops at the Gateway. When the Gateway forwards the call to the Runtime behind it, it re-authenticates as a service, using an OAuth2 client-credentials (M2M) token — no user involved, minted from the same Cognito pool with an mcp.invoke scope. The Runtime's authorizer lists both the user client and the M2M client in allowedClients, so it accepts either.

That separation is the point: you centralize "who may call the system" (inbound, user identity) independently of "how the platform talks to its own backends" (outbound, service identity). Client-credentials has no user because a service calling a service has no user — exactly the right primitive. Conflating the two is how you end up trying to forward a user's short-lived token across an internal hop and wondering why it expired mid-request.

What I Learned

The reference architecture's identity provider is load-bearing

The AWS DCR walkthrough works because it uses Auth0. Swap the IdP and the whole mechanism can evaporate — Cognito supports neither dynamic registration nor aud claims. Always check whether the provider supports the pattern before you build on the pattern.

Don't out-engineer a structural limitation

I built a DCR proxy and sketched a metadata shim before accepting that the platform advertises the issuer's real identity no matter what I serve. The correct response to "the provider can't do X" is usually "use the path the provider can do," not "build a shadow provider."

oauth.clientId is the entire escape hatch

One field in the host config — a pre-registered public client ID — turns "spec-compliant client dies on DCR" into "client logs in normally." It's the single most useful thing to know about connecting real MCP hosts to a Cognito-backed server.

Every host is its own OAuth client, and they don't agree

Trailing slashes, scope derivation, localhost vs 127.0.0.1, cached auth providers, hosted relays. Getting the server right is necessary and nowhere near sufficient — budget real time for the per-client gauntlet, and debug from the actual /authorize URL the client opens, not from what you think it should send.

Validate on what the token actually carries

No aud claim means you validate on client_id via an allow-list. That's not a workaround to be ashamed of — it's the correct thing to do given what a Cognito access token contains. Match your validation to the token you actually get, not the token the spec assumes.

A Note on the Two Directions

If you take one structural idea away: inbound and outbound auth are different problems with different primitives. Inbound is a user proving identity through a client you don't control — that's where DCR, static client IDs, and PKCE live. Outbound is the platform's services authenticating to each other — that's where machine-to-machine client-credentials lives. Keep them separate, put a clear trust boundary between them (the Gateway), and each stays simple. I deploy this exact split in the Kanban MCP App: building an interactive Kanban board on AgentCore, where the same Cognito pool serves both a public user client (inbound) and an M2M client (Gateway → Runtime outbound).

#mcp#oauth#cognito#agentcore#bedrock#aws#dcr#authentication
Share