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, 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.
1. Cognito rejects the RFC 8707 resource indicator that MCP insists on sending
MCP's authorization spec wants access tokens to be audience-bound. The mechanism it points at is RFC 8707 Resource Indicators: 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.
2. Cognito access tokens carry no 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.
3. Managed Login v2 renders "Login pages unavailable" for any client with no branding style
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.
4. Elicitation is not supported by connector hosts yet - which we proved, not assumed
The MCP spec has 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.
The through-line
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.
Top comments (0)