When I added OAuth to the MixCraft MCP server, the identity provider was Clerk, and Clerk did the whole job: it was the OAuth authorization server the MCP client talked to, it signed in the user, and it issued the tokens the MCP server verified. The next remote MCP server I put behind a login had users who already sign in through Okta, so Okta had to be where the sign-in happened. Okta is the identity provider here, but most of the configuration is on the Cognito side: a Cognito user pool federates to Okta over SAML, acts as the OAuth authorization server, and issues the tokens the MCP server checks. Okta's part is a SAML application pointed at the pool, and the MCP server's part is the routes and the verifier below.
The server itself is a Python FastAPI app serving a Streamable HTTP MCP endpoint at /mcp. To put that endpoint behind Cognito, the app gains four more routes and one verification function, and the CDK gains two Cognito resources per MCP server: a resource server, which names the URL tokens are bound to, and an app client, which MCP clients identify themselves with.
The shape
A Cognito user pool is the authorization server. An Okta SAML identity provider can be attached to the pool, so the sign-in page Cognito serves — its managed login — hands the user to Okta and takes a SAML assertion back. Cognito issues the access token, and the MCP server verifies it against the pool's published signing keys.
Inside the pool, each MCP server has two Cognito resources of its own. A resource server whose identifier is the MCP endpoint's URL lets Cognito issue a token with that URL in its aud claim, and MCP clients identify themselves with an app client. The pool and the Okta provider change rarely and can be shared by other MCP servers; the resource server and app client are specific to each MCP server. I split them into separate CDK stacks along that line, with the pool's stack publishing what the others need as SSM parameters.
graph TB
subgraph Okta
app[SAML application]
end
subgraph pool[Cognito user pool — shared stack]
idp[Okta SAML identity provider]
login[Managed login domain]
end
subgraph per[Per MCP server — its own stack]
srv["MCP server<br/>/mcp + 4 routes + verifier"]
rs["Resource server<br/>identifier = https://…/mcp"]
client[App client — public, PKCE]
branding[Managed login branding]
end
app --> idp
idp --> login
srv --> rs
srv --> client
branding --> client
client --> idp
The sign-in itself, from the client's first unauthenticated request to the first authenticated one:
sequenceDiagram
participant C as MCP client
participant S as MCP server
participant G as Cognito
participant O as Okta
C->>S: POST /mcp (no token)
S-->>C: 401 + WWW-Authenticate resource_metadata
C->>S: GET /.well-known/oauth-protected-resource
C->>S: GET /.well-known/oauth-authorization-server
C->>S: GET /oauth/authorize?resource=…&code_challenge=…
S-->>C: 302 to Cognito managed login (identity_provider=Okta)
C->>G: /oauth2/authorize
G->>O: SAML sign-in
O-->>G: assertion
G-->>C: redirect_uri?code=…
C->>S: POST /oauth/token (code + verifier + resource)
S->>G: POST /oauth2/token (forwarded as-is)
G-->>S: access token, aud = https://…/mcp
S-->>C: same response, byte for byte
C->>S: POST /mcp, Authorization: Bearer …
S->>G: JWKS (cached)
S-->>C: MCP response
Why the MCP server serves OAuth routes at all
The MCP authorization spec has the client locate the authorization server in two steps. It reads the MCP server's protected resource metadata — a JSON document at /.well-known/oauth-protected-resource listing the server's authorization_servers — and then it reads the authorization server's own metadata at a well-known URL derived from that issuer. The spec's discovery section says:
MCP clients MUST attempt multiple well-known endpoints when discovering authorization server metadata.
and lists, for an issuer with a path component, three URLs in priority order: RFC 8414's /.well-known/oauth-authorization-server/{path}, then OpenID Connect Discovery with the path inserted, then OpenID Connect Discovery with the path appended. The issuer of a Cognito user pool is https://cognito-idp.{region}.amazonaws.com/{poolId}, and the only one of those three documents Cognito serves is the last, the OpenID one at {issuer}/.well-known/openid-configuration.
With authorization_servers pointed straight at that issuer, the client I tested with, Claude Code, did not complete discovery against it: it fell back to treating the MCP server's own origin as the authorization server and asked there for the metadata. Cognito's OpenID document also lists its authorization and token endpoints on a different host from the issuer — the managed login domain — so even a client that reads it is being sent across origins for the rest of the flow.
So the MCP server publishes the authorization server metadata itself, at /.well-known/oauth-authorization-server on its own origin, with issuer, authorization_endpoint, and token_endpoint all pointing at itself. The two endpoints that document advertises, /oauth/authorize and /oauth/token, are proxies: authorize redirects the browser to Cognito's authorization endpoint, and token forwards the code exchange to Cognito's token endpoint and returns the response. Only jwks_uri points at Cognito, because Cognito holds the keys that verify the tokens it issued.
graph TB
subgraph origin[MCP server origin — https://myapp.example.com]
prm[/.well-known/oauth-protected-resource/]
asm[/.well-known/oauth-authorization-server/]
authz[/oauth/authorize/]
tok[/oauth/token/]
mcp[/mcp/]
end
subgraph login[Cognito managed login — prefix.auth…]
cauthz[/oauth2/authorize/]
ctok[/oauth2/token/]
end
subgraph issuer[Cognito issuer — cognito-idp…/poolId]
jwks[/.well-known/jwks.json/]
end
prm -- authorization_servers --> asm
asm -- authorization_endpoint --> authz
asm -- token_endpoint --> tok
authz -- 302 --> cauthz
tok -- POST, forwarded --> ctok
asm -- jwks_uri --> jwks
mcp -- verify signature --> jwks
Three hosts are involved. The metadata document on the MCP server's origin ties them together for the client: every endpoint it lists is on the same origin except jwks_uri, and the client never needs to know the managed login host exists.
The pool, the Okta provider, and the SSM contract
this.userPool = new cognito.UserPool(this, 'UserPool', {
userPoolName: `mcp-auth-${envName}`,
featurePlan: cognito.FeaturePlan.ESSENTIALS,
selfSignUpEnabled: false,
signInAliases: { email: true },
accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
});
this.userPool.addDomain('HostedUiDomain', {
cognitoDomain: { domainPrefix },
managedLoginVersion:
cognito.ManagedLoginVersion.NEWER_MANAGED_LOGIN,
});
new cognito.UserPoolIdentityProviderSaml(this, 'Okta', {
userPool: this.userPool,
name: IDP_NAME,
metadata: cognito.UserPoolIdentityProviderSamlMetadata
.url(oktaMetadataUrl),
attributeMapping: {
email: cognito.ProviderAttribute.other(EMAIL_CLAIM),
custom: {
email_verified: cognito.ProviderAttribute.other('emailVerified'),
},
},
});
const hostedUiDomain =
`${domainPrefix}.auth.${region}.amazoncognito.com`;
for (const [name, value] of Object.entries({
'user-pool-id': this.userPool.userPoolId,
'hosted-ui-domain': hostedUiDomain,
'idp-name': IDP_NAME,
})) {
new ssm.StringParameter(this, `Param-${name}`, {
parameterName: `/platform/${envName}/auth/${name}`,
stringValue: value,
});
}
Two of these settings are declared rather than left to defaults. Resource binding — binding an access token to one MCP server through the resource parameter — is "exclusive to managed login authentication", and managed login is available only on the Essentials and Plus feature plans, so the plan and the managed login version are both set explicitly. On the classic hosted UI the resource parameter is accepted and ignored.
The attribute mapping pulls the email address out of the SAML assertion, and maps email_verified as well because mapped attributes are re-applied at every sign-in. Without that mapping the attribute can drift to false, and a resource server that checks it will start rejecting tokens for users who signed in fine.
The three SSM parameters are the entire contract between this stack and any stack that adds an MCP server to the pool. Each MCP server's stack reads them with StringParameter.valueForStringParameter and imports the pool with UserPool.fromUserPoolId, so adding a server touches only that server's own stack.
The resource server and app client
const poolId = ssm.StringParameter.valueForStringParameter(
this, `/platform/${envName}/auth/user-pool-id`);
const idpName = ssm.StringParameter.valueForStringParameter(
this, `/platform/${envName}/auth/idp-name`);
const pool = cognito.UserPool.fromUserPoolId(this, 'Pool', poolId);
new cognito.CfnUserPoolResourceServer(this, 'McpResourceServer', {
userPoolId: poolId,
identifier: `${publicBaseUrl}/mcp`, // the URL the server answers on
name: `myapp-mcp-${envName}`,
});
const client = pool.addClient('Mcp', {
userPoolClientName: `myapp-mcp-${envName}`,
supportedIdentityProviders: [
cognito.UserPoolClientIdentityProvider.custom(idpName),
],
oAuth: {
flows: { authorizationCodeGrant: true },
scopes: [cognito.OAuthScope.OPENID],
callbackUrls: MCP_CALLBACK_URLS, // exact match, no wildcards
},
authFlows: {}, // no password, no SRP
});
new cognito.CfnManagedLoginBranding(this, 'McpLoginBranding', {
userPoolId: poolId,
clientId: client.userPoolClientId,
useCognitoProvidedValues: true,
});
The resource server's identifier is the URL the MCP endpoint answers on, and it has to be exactly that: the identifier becomes the aud claim in the access token, and it is the value the MCP client sends as resource. Cognito's documentation describes resource as any URL of your choosing, with a registered resource server as one option; in my testing, without a resource server registered under that identifier the authorization request went through and the code exchange failed with invalid_grant — the failure surfacing one step after the request that caused it. Registering the resource server is the fix either way.
The app client is a public client in OAuth's terms: it has no client secret, because the MCP clients that use it — Claude Code, Claude Desktop, anything running on a user's machine — cannot keep a secret confidential. Proof that the same client that started the flow is finishing it comes from PKCE instead, which every MCP client is required to implement. In CDK that means omitting generateSecret rather than setting it to false, because an explicit false renders as a literal property in the template instead of leaving it unset. authFlows: {} turns off the username-and-password and SRP sign-in flows on this client, so the managed login page and Okta are the only way to get a token from it, and supportedIdentityProviders lists only Okta so Cognito never shows its own provider-picker page.
Cognito has no dynamic client registration, so every redirect URI an MCP client will use has to be in callbackUrls ahead of time, and Cognito matches them exactly. The client id is published as a stack output, because the user pastes it into their MCP client configuration.
The last resource, CfnManagedLoginBranding, exists because managed login renders its pages from a per-client branding style — colors, logo, layout. An app client created in the console gets a style assigned automatically; one created through CloudFormation or the API does not, and managed login will not serve a client that has none. useCognitoProvidedValues: true assigns the default style without customizing anything. Nothing else is in the stack for auth: no KMS key, no DynamoDB table, no secret, and no IAM grants for any of them.
The server's runtime gets the pool id, the managed login domain, the provider name, the client id, and its own public base URL as environment variables.
The two metadata documents
The MCP client learns everything above from two JSON documents the MCP server serves on its own origin, as routes in the same FastAPI app as /mcp. The first is the protected resource metadata: it names this server's canonical URL as resource and lists its authorization servers. The second is the authorization server metadata: the issuer and the endpoints a client uses to obtain a token. A client reads authorization_servers[0] from the first and, having fetched the second from that URL, checks that its issuer is identical, so both derive from the same base URL and cannot disagree.
@router.get("/.well-known/oauth-protected-resource")
@router.get("/.well-known/oauth-protected-resource/mcp")
def protected_resource_metadata(request, settings):
base = str(request.base_url).rstrip("/")
return ProtectedResourceMetadata(
resource=f"{base}/mcp",
authorization_servers=[base],
scopes_supported=["openid"],
bearer_methods_supported=["header"],
)
@router.get("/.well-known/oauth-authorization-server")
def authorization_server_metadata(request, settings):
base = str(request.base_url).rstrip("/")
return AuthorizationServerMetadata(
issuer=base,
authorization_endpoint=f"{base}/oauth/authorize",
token_endpoint=f"{base}/oauth/token",
jwks_uri=f"{cognito_issuer(settings)}/.well-known/jwks.json",
code_challenge_methods_supported=["S256"],
grant_types_supported=["authorization_code", "refresh_token"],
)
The protected resource document is served at both the root well-known path and the path-suffixed one, because the spec has clients try the suffixed form first when the MCP endpoint is not at the root.
The authorize and token proxies
/oauth/authorize is the authorization_endpoint from the document above: the URL an MCP client opens in the browser to start the sign-in, carrying client_id, redirect_uri, the PKCE challenge, and resource. The route is a proxy to Cognito's own authorization endpoint, https://{managed-login-domain}/oauth2/authorize, and Cognito validates client_id, redirect_uri, and the PKCE parameters against the app client when the redirect lands there. The one parameter the proxy checks itself is resource, because a client asking for a token bound to some other server's URL should get an error here rather than be redirected as though it had asked for this one.
@router.get("/oauth/authorize")
def authorize(request, resource: str = "",
settings=Depends(get_settings)):
expected = f"{str(request.base_url).rstrip('/')}/mcp"
if resource != expected:
raise HTTPException(400, {"error": "invalid_target"})
params = dict(request.query_params) # forwarded raw
params["identity_provider"] = settings.cognito_idp_name
return RedirectResponse(
f"https://{settings.cognito_hosted_ui_domain}"
f"/oauth2/authorize?{urlencode(params)}",
status_code=302,
)
The query parameters are passed through as received rather than rebuilt from a known list, so a parameter this code doesn't know about still reaches Cognito. identity_provider is added so that managed login sends the user straight to Okta.
/oauth/token is the token_endpoint, where the client exchanges the authorization code and PKCE verifier for tokens. It forwards the form body to Cognito's token endpoint unchanged — including resource, which the MCP spec requires clients to send and Cognito accepts — and returns Cognito's response with the same body and status code, so the client receives exactly the tokens or the error Cognito produced.
@router.post("/oauth/token")
async def token(request, settings=Depends(get_settings)):
form = await request.form()
data = {k: str(v) for k, v in form.items()}
try:
upstream = httpx.post(
f"https://{settings.cognito_hosted_ui_domain}/oauth2/token",
data=data,
headers={"Content-Type":
"application/x-www-form-urlencoded"},
)
except httpx.HTTPError as exc:
logger.warning("token forward failed (%s)", type(exc).__name__)
return JSONResponse({"error": "server_error"}, status_code=502)
return Response(content=upstream.content,
status_code=upstream.status_code,
media_type="application/json")
Verifying the token on /mcp
The access token Cognito issues is a JWT signed with the pool's RSA key, and the MCP server verifies it the way Cognito documents: fetch the pool's JWKS, check the signature, check iss is the pool, and check exp. Those checks establish that Cognito issued the token to someone who signed in through Okta. They do not establish that it was issued for this MCP server, since every MCP server on the pool gets tokens signed by the same key with the same issuer. Two claims do that: aud, which carries the resource server identifier the client asked for, and client_id, which names the app client the token was issued through. The spec requires the audience check:
MCP servers MUST validate that access tokens were issued specifically for them as the intended audience, according to RFC 8707 Section 2.
def _decode(token, settings, audience):
issuer = cognito_issuer(settings)
key = _jwk_client(issuer).get_signing_key_from_jwt(token)
return jwt.decode(
token, key.key, algorithms=["RS256"],
issuer=issuer,
audience=audience, # this server's URL
options={"require": ["exp", "aud", "iss"]},
)
def verify_access_token(token, settings, audience):
try:
claims = _decode(token, settings, audience)
except (jwt.PyJWTError, KeyError, ValueError, TypeError) as exc:
raise McpAuthError(f"verify failed ({type(exc).__name__})")
if claims.get("token_use") != "access":
raise McpAuthError("expected an access token")
if claims.get("client_id") != settings.mcp_oauth_client_id:
raise McpAuthError("client_id does not match")
sub, username = claims.get("sub"), claims.get("username")
if not isinstance(sub, str) or not isinstance(username, str):
raise McpAuthError("missing sub or username")
return McpPrincipal(subject=sub, username=username)
token_use is checked separately from aud because Cognito issues an ID token alongside the access token, and the ID token also carries an aud claim — the app client id — so a client that presented its ID token as a bearer token would otherwise get through. The require option makes a missing claim a failure in its own right: if resource is dropped anywhere in the chain, Cognito issues a token with no aud at all, and that token fails here rather than passing with one check fewer. Only the exception type is logged, never the token.
The verifier runs as middleware in front of the MCP application, so a rejected request never reaches a tool. The audience it demands and the resource value in the 401 challenge and the protected resource document come from the same function; if the two ever disagreed, a conforming client would loop forever between them.
What this adds up to
Cognito ends up doing everything the MixCraft post had Clerk do — authorization server, sign-in, token issuer — with Okta behind it as the identity provider, and the MCP server's own code stays small: two metadata documents, two routes that forward to Cognito, and one verification function. The Cognito configuration is where we do the heavy lifting - the feature plan and managed login that resource binding depends on, the resource server whose identifier has to equal the MCP endpoint's URL, the public app client with exact callback URLs, and the branding style a CloudFormation-created client needs before managed login will serve it. The user pool and the Okta provider are set up once; adding another MCP server to the pool is another stack reading the same three SSM parameters, plus the same two documents, two routes, and verifier in that server's own code.