The Cognito war stories: four ways it breaks an MCP server, and the fix for each A developer detailed four ways Amazon Cognito breaks an MCP server's OAuth flow, discovered while wiring an agent OAuth flow for cogDepot. The issues include Cognito's lack of support for RFC 8707 resource indicators, requiring a proxy to strip the resource parameter, and the need to validate client_id instead of aud for access tokens. The fixes involve a thin OAuth proxy and adjusted token verification. If you are putting Amazon Cognito behind a remote MCP server, here are four specific ways it will break the connector before you ever reach your own code - each with the symptom, the reason, and the fix that got us through it. All four came out of one week of wiring an agent OAuth flow for cogDepot https://cogdepot.com , and none of them are in the happy-path docs. None of this is competitively sensitive. It is just the friction between two specs that were written by different people who never had to make them meet. MCP's authorization spec wants access tokens to be audience-bound. The mechanism it points at is RFC 8707 Resource Indicators https://datatracker.ietf.org/doc/html/rfc8707 : the client appends a resource parameter to the /authorize and /token requests naming the server it wants a token for, and the authorization server is supposed to bind the resulting token to that resource. A spec-compliant MCP client does this unconditionally. Claude's connector does. You do not get to turn it off from the client side, and you should not want to - it is the client behaving correctly. Cognito does not implement RFC 8707. It does not bind the token to the resource; it rejects the request for carrying an unrecognized parameter. So the connector's very first hop - the redirect to /authorize - dies before the user ever sees a login box. You cannot fix this in Cognito's configuration and you cannot fix it in the client. The fix is a thin, same-origin OAuth proxy sitting in front of Cognito's /authorize and /token . It forwards everything through untouched except for one surgical edit: it strips the resource parameter on the way in. The part that will bite you if you are careless about it: strip resource and nothing else . In particular code challenge and code challenge method have to survive verbatim, or you break PKCE and trade one failure for another. Our verification checklist for the proxy is exactly that - " /oauth/authorize strips the RFC 8707 resource param while preserving code challenge " - because the first cut of the proxy is where you accidentally drop the wrong one. The audience-binding that RFC 8707 was supposed to give you, you now have to enforce yourself at the resource server. Which is the next story. aud , so you pin on client id MCP is blunt about audience validation: MCP servers MUST validate that access tokens were issued specifically for them as the intended audience, according to RFC 8707 Section 2. ... MCP servers MUST NOT accept or transit any other tokens. The reflex, if you have validated ID tokens before, is to check the aud claim. That is correct for a Cognito ID token. It is useless for a Cognito access token, because a Cognito access token has no aud claim at all. The client that the token was minted for is named in client id instead, and the token carries token use: access where the ID token carries token use: id . So the audience check that satisfies the MCP requirement is a client id check. Two claims move relative to the ID-token path: you compare client id against your configured client, and you require token use == "access" . Here is the verifier we run, trimmed to the claim checks: // An access token differs from an ID token in two ways that matter here: it // carries no aud claim the client is named by client id , and it carries // authorization scope . The verifier checks client id where the ID-token // path checks aud , and token use=access where the ID path requires id . if claims.Iss = v.issuer { return nil, fmt.Errorf "cognito: unexpected issuer %q", claims.Iss } // The binding that stands in for the absent aud on an access token. if claims.ClientID = v.clientID { return nil, fmt.Errorf "cognito: unexpected client id %q", claims.ClientID } if claims.TokenUse = "access" { return nil, fmt.Errorf "cognito: unexpected token use %q", claims.TokenUse } Miss the token use check and an ID token minted for the same client sails straight through your access-token path. Miss the client id check and any access token from the same user pool - including one issued to a completely different app client - is accepted as yours. The pool is the trust boundary Cognito gives you for free; the client is the one you have to draw yourself. One more thing worth pinning while you are in there: accept exactly one signing algorithm RS256 and one key type RSA , compared against the values in the attacker-supplied token header rather than inferred from it. Algorithm confusion needs a negotiable alg field to exist. Do not give it one. This one produces the least helpful error message of the four. You move a user pool to Managed Login the version-2 hosted UI , point a new app client at the same domain, hit its login URL, and get: Login pages unavailable. Please contact an administrator. Nothing is misconfigured in the obvious places. The domain resolves, the client exists, the pool is on the right feature plan. What is missing is a branding style for that specific client. On a version-2 domain, Managed Login will not render a login page for a client that has no style applied - it does not fall back to a default, it refuses. The web client worked because it had a style; the freshly-added agent client hit the wall precisely because it did not. The fix is to give every client its own managed-login branding resource, even if it is a near-clone of another client's. In Terraform that is awscc cognito managed login branding the aws provider's equivalent is a v6 resource; the underlying call is the same CreateManagedLoginBranding either way . Ours reuses the web client's palette and logo assets byte-for-byte, with a single deliberate difference: the agent client's page shows the federated sign-in buttons, because unlike the web flow there is no first-party page in front to deep-link the user into a specific identity provider. While you are here, one gotcha that turns a working style into a recurring outage: Cognito marks the branding style's ClientId as create-only, and Cloud Control's in-place update is a JSON patch that always contains an "add ClientId" op it is not allowed to apply. The first apply succeeds; every later colour or logo tweak fails with: NotUpdatableException: Invalid patch update: createOnlyProperties /properties/ClientId cannot be updated So configure the style to be replaced on change, not updated - replace triggered by a hash of the settings and asset files. The cost is a few seconds of unstyled login during the destroy-then-create, which is fine for a settings change and is the only supported path for a create-only property. The MCP spec has elicitation https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation : mid-tool-call, the server can ask the client to collect a piece of input from the user and hand it back. It is the natural home for a confirmation step - "this action will spend N credits, confirm?" - and the spec describes it cleanly enough that it is very tempting to design a flow around it and move on. We did not design around it, because the spec describing a capability and a given host implementing it are different facts. Before leaning on elicitation for anything load-bearing, we built the smallest possible throwaway server that does nothing but issue one elicitInput call, pointed a real connector host at it, and watched what came back. The answer today is that connector hosts do not support elicitation yet. Reading the spec would have told you the shape of the round-trip; it would not have told you the host silently declines to make it. The spike cost an afternoon and turned a guess into a fact. The consequence for the design is that we did not ship anything whose safety depends on an elicitation round-trip. The confirmation that elicitation would have carried lives somewhere the host is guaranteed to honour instead. The general rule, which is older than MCP: when a capability sits on the far side of a host you do not control, the smallest experiment that exercises it end-to-end is cheaper than the bug you ship by assuming it works. Three of these four are the same shape: a standards body wrote an obligation, an implementation you depend on does not meet it, and the gap lands in your lap at the integration seam. RFC 8707 says bind the token; Cognito does not, so you strip and rebind. MCP says validate the audience; Cognito's access token has no audience field, so you validate the client instead. The spec says elicitation exists; the host has not built it, so you find out before you depend on it. The only defense that generalizes is to verify the seam rather than trust it - proxy what the provider rejects, check the claim that is actually present rather than the one you expected, and spike the capability against the real host. Every one of these was an afternoon once we stopped assuming and started looking.