AWS Cognito · 2026-10-10 · Cognito arc
AWS Cognito for MCP Servers: User Pools, JWT Verification, OAuth2 Flows, and Identity Pools
Four Cognito topics composed into three structural patterns for MCP server authentication — user pool provisioning (immutable decisions: UsernameAttributes and Schema cannot change after creation; public clients have no secret and require PKCE; confidential clients have a generated secret and support client credentials for M2M; resource server scopes are prefixed identifier/scope-name and must be listed on both the resource server and every app client that uses them; token lifetimes: access token 60 minutes recommended, ID token matches access token, refresh token 30 days for users and 1 day for server-side clients; deletion protection must be disabled before pool deletion — set --deletion-protection ACTIVE at creation), JWT verification middleware (JWKS endpoint: https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json; cache keys in memory with 24-hour TTL indexed by kid; on kid-not-found refetch once — Cognito adds new key before removing old during rotation; mandatory claims: iss exact-match to user pool URL, token_use == "access", exp > now, client_id matches your app client — access tokens use client_id not aud for the audience claim, unlike ID tokens which use aud; never send the ID token to the MCP server API — reject if token_use != "access"; scope claim is space-separated; pre-warm JWKS cache at server startup to avoid 50–200ms cold miss on first request), OAuth2 flows (PKCE for humans: code_verifier = 43–128 URL-safe base64 characters from crypto.randomBytes(96).toString('base64url'); code_challenge = BASE64URL(SHA256(ASCII(code_verifier))) — hash the raw bytes, not the hex string, no padding; codes are single-use and 10-minute TTL; invalid_grant on reuse, expiry, or PKCE mismatch; state parameter prevents CSRF — store in server-side session backed by Redis/DynamoDB not process memory; client credentials for M2M: POST to /oauth2/token with grant_type=client_credentials and Basic auth credentials; returns access_token only, no id_token and no refresh_token; cache until exp - 60s; OIDC scopes not supported in client credentials flow), and Identity Pools for federated AWS credentials (two-step exchange: GetId with User Pool ID token → stable Cognito Identity ID; GetCredentialsForIdentity with Identity ID + same ID token → STS AccessKeyId/SecretKey/SessionToken expiring in ~1 hour; ServerSideTokenCheck=true validates token revocation server-side; ${cognito-identity.amazonaws.com:sub} in IAM conditions = the Identity Pool sub, which differs from the User Pool sub — row-level DynamoDB isolation via dynamodb:LeadingKeys, S3 per-user prefix via Resource ARN; enhanced auth flow lets the identity pool pick the IAM role based on Cognito group membership; basic flow lets the client specify a custom-role-arn, requires AllowClassicFlow=true; use @aws-sdk/credential-providers fromCognitoIdentityPool for automatic credential refresh). 14-row consolidated failure modes table.
Pattern 1: The user pool setup contract — immutable decisions and client types
AWS Cognito User Pool setup has a set of decisions that are locked at creation time and cannot be changed without creating a new pool. Understanding which decisions are immutable — and why — is the difference between a clean production deployment and a painful migration months later. The User Pools guide covers the complete provisioning sequence.
Immutable decisions at pool creation. Two settings in particular cannot be changed after the pool is created: UsernameAttributes (whether users sign in with email, phone number, or a username) and Schema (the attribute definitions). If you use email as the sign-in attribute (--username-attributes email), users always log in with their email — you cannot later switch to a username. Similarly, once you define a custom attribute like custom:plan in the schema, the attribute name and type are permanent. The Mutable flag on each attribute controls whether users or admins can update the value, but the attribute itself cannot be removed.
# Critical immutable settings — decide these before running create-user-pool
# UsernameAttributes: email | phone_number | (omit for username-based login)
# Schema: custom attributes cannot be removed after creation; Mutable controls value changes
aws cognito-idp create-user-pool \
--pool-name mcp-server-users \
--username-attributes email \
--auto-verified-attributes email \
--policies '{
"PasswordPolicy": {
"MinimumLength": 12,
"RequireUppercase": true,
"RequireLowercase": true,
"RequireNumbers": true,
"RequireSymbols": false
}
}' \
--mfa-configuration OPTIONAL \
--deletion-protection ACTIVE \
--region us-east-1
Public clients vs confidential clients. Every application that authenticates users gets its own app client. The fundamental split is whether the client can keep a secret:
| Setting | Public client (SPA, native, Claude Desktop) | Confidential client (server-side, M2M) |
|---|---|---|
| Client secret | None — omit --generate-secret | Generated by Cognito; stored in Secrets Manager |
| PKCE requirement | Required — replaces the client secret for code exchange | Optional; client secret used instead, but PKCE still recommended |
| Auth flows | Authorization Code + PKCE; SRP; refresh token | Code; client credentials; SRP; user password; refresh token |
| Client credentials grant | Not supported — requires a client secret | Supported — use for M2M tokens with no user context |
| Refresh token validity | 30 days — users shouldn't have to log in daily | 1 day; M2M should use client credentials, not refresh tokens |
| Typical MCP use case | Claude Desktop, browser agent, mobile app | Server-side proxy, automated pipeline, health-check monitor |
The most common misconfiguration is creating a public client with a client secret enabled. When a browser or native app tries to authenticate, Cognito rejects the request with NotAuthorizedException: Unable to verify secret hash for client because the app cannot safely compute the required HMAC signature over the secret. The fix is a new app client created with --no-generate-secret.
Resource servers and custom scopes. Before creating clients, define the resource server that represents your MCP server API. Scopes are namespaced with the resource server identifier: https://api.mcp-server.example.com/tools:invoke. Defining a scope on the resource server is not enough — you must also add it to each app client's AllowedOAuthScopes. Adding a scope to the resource server after clients exist does not grant it to any existing client automatically.
# Resource server: defines the MCP API and its custom scopes
aws cognito-idp create-resource-server \
--user-pool-id $USER_POOL_ID \
--identifier https://api.mcp-server.example.com \
--name "MCP Server API" \
--scopes '[
{"ScopeName": "tools:invoke", "ScopeDescription": "Invoke MCP tools"},
{"ScopeName": "tools:read", "ScopeDescription": "Read MCP tool definitions"},
{"ScopeName": "admin", "ScopeDescription": "Administrative access"}
]'
# Public app client — must list the scopes from the resource server explicitly
aws cognito-idp create-user-pool-client \
--user-pool-id $USER_POOL_ID \
--client-name mcp-spa-client \
--no-generate-secret \
--allowed-o-auth-flows code \
--allowed-o-auth-flows-user-pool-client \
--allowed-o-auth-scopes \
openid email profile \
"https://api.mcp-server.example.com/tools:invoke" \
"https://api.mcp-server.example.com/tools:read" \
--callback-urls "https://mcp-server.example.com/callback" \
--token-validity-units '{"AccessToken":"minutes","IdToken":"minutes","RefreshToken":"days"}' \
--access-token-validity 60 \
--id-token-validity 60 \
--refresh-token-validity 30 \
--enable-token-revocation
Token lifetime recommendations. The access token and ID token are always issued together and refreshed together — set them to the same value (60 minutes). The refresh token controls how long a user can stay logged in without re-authenticating: 30 days is appropriate for interactive users; 1 day for server-side confidential clients (though M2M workloads should prefer the stateless client credentials flow over long-lived refresh tokens). MFA is worth setting to OPTIONAL at pool creation rather than OFF — it lets security-conscious users enroll TOTP without forcing it on everyone immediately, and switching from OFF to REQUIRED later requires all users to enroll before their next login.
Pattern 2: The JWT verification contract — JWKS caching, mandatory claims, and key rotation
Every authenticated request to your MCP server arrives with a Cognito access token in the Authorization: Bearer header. Verifying it correctly requires a specific sequence of checks — and the order matters. The JWT verification guide provides the complete middleware implementation.
The JWKS caching strategy. Cognito signs all JWTs using RSA-256 (RS256) with private keys. Your MCP server verifies signatures using the corresponding public keys, which Cognito publishes at a well-known URL:
# JWKS endpoint URL — fetch once, cache for 24 hours
# https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json
curl -s "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_AbCdEfGhI/.well-known/jwks.json" | jq .
# Returns:
# { "keys": [
# { "alg":"RS256", "e":"AQAB", "kid":"key-id-1", "kty":"RSA", "n":"long-modulus", "use":"sig" },
# { "alg":"RS256", "e":"AQAB", "kid":"key-id-2", "kty":"RSA", "n":"another-modulus", "use":"sig" }
# ] }
The kid (key ID) in each JWT header identifies which JWKS key was used to sign the token. Your verification code looks up the matching key, converts the JWK to a PEM public key, and verifies the signature. The critical rules for caching:
- Cache the JWKS keyset indexed by
kidwith a 24-hour TTL. Long enough to avoid hammering Cognito's endpoint under load; short enough to pick up new keys after a rotation well before the old key is removed. - On verification failure due to
kidnot found in cache: refetch the JWKS once and retry. If the key is still absent, the token is genuinely invalid — never loop or accept an unknown key. - Pre-warm the cache at server startup. The first request after a cold start incurs a 50–200ms JWKS fetch; subsequent requests run at sub-millisecond latency from cache.
- Never hardcode key values. Hardcoded keys break silently at the next rotation with no error until a redeploy happens.
The jwks-rsa library implements this caching strategy for Node.js:
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const REGION = process.env.AWS_REGION;
const USER_POOL_ID = process.env.COGNITO_USER_POOL_ID;
const CLIENT_ID = process.env.COGNITO_CLIENT_ID;
const ISSUER = `https://cognito-idp.${REGION}.amazonaws.com/${USER_POOL_ID}`;
const jwks = jwksClient({
jwksUri: `${ISSUER}/.well-known/jwks.json`,
cache: true,
cacheMaxEntries: 10,
cacheMaxAge: 24 * 60 * 60 * 1000, // 24 hours
rateLimit: true,
jwksRequestsPerMinute: 10,
});
// Pre-warm at startup — avoids cold miss on first request
jwks.getSigningKeys((err, keys) => {
if (err) console.error('JWKS pre-warm failed:', err.message);
else console.log(`JWKS pre-warmed: ${keys.length} keys cached`);
});
Mandatory claims verification. Checking the signature is not enough. After the signature verifies, four claims must be validated manually — jsonwebtoken does not check all of them automatically:
| Claim | Expected value | Why it matters |
|---|---|---|
iss | Exact match: https://cognito-idp.{region}.amazonaws.com/{userPoolId} | Prevents tokens from one user pool from being accepted by a different pool |
token_use | "access" | ID tokens have different security properties — reject if a client sends an ID token to the API |
exp | exp > Date.now() / 1000 (allow 30s clock skew) | Expired tokens must not be accepted regardless of valid signature |
client_id | Must equal your app client ID | Access tokens use client_id for the audience claim — aud is only used in ID tokens |
The access-token vs ID-token confusion is one of the most common bugs in MCP server authentication. Access tokens and ID tokens are both issued by the same token endpoint at the same time — a client that stores both and accidentally sends the ID token will get a valid signature check but fail token_use validation. Always check token_use === "access" before authorizing any request.
function requireAuth(req, res, next) {
const authHeader = req.headers['authorization'];
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'unauthorized' });
}
const token = authHeader.slice(7);
jwt.verify(token, getSigningKey, { algorithms: ['RS256'], issuer: ISSUER }, (err, decoded) => {
if (err) {
return res.status(err.name === 'TokenExpiredError' ? 401 : 403)
.json({ error: 'invalid_token', error_description: err.message });
}
// Claims jsonwebtoken does not check automatically for Cognito access tokens
if (decoded.token_use !== 'access') {
return res.status(403).json({
error: 'invalid_token',
error_description: 'token_use must be "access" — do not send ID tokens to the API',
});
}
if (decoded.client_id !== CLIENT_ID) {
return res.status(403).json({ error: 'invalid_token', error_description: 'Wrong client_id' });
}
req.auth = decoded;
req.userId = decoded.sub;
req.userGroups = decoded['cognito:groups'] || [];
req.tokenScopes = (decoded.scope || '').split(' ');
next();
});
}
Scope and group authorization. After the token is verified, the scope claim drives fine-grained API authorization. Cognito access tokens carry scopes as a space-separated string. Groups — stored in the cognito:groups array — represent user identity categories (plan tiers, admin status) and work alongside scopes:
// Scope middleware: what the token is allowed to do
function requireScope(requiredScope) {
return (req, res, next) => {
if (!req.tokenScopes.includes(requiredScope)) {
return res.status(403).json({ error: 'insufficient_scope', scope: requiredScope });
}
next();
};
}
// Group middleware: who the user is
function requireGroup(groupName) {
return (req, res, next) => {
if (!req.userGroups.includes(groupName)) {
return res.status(403).json({ error: 'forbidden', group: groupName });
}
next();
};
}
// Combining scope and group checks
const TOOL_SCOPE = 'https://api.mcp-server.example.com/tools:invoke';
app.post('/mcp/tools/invoke', requireAuth, requireScope(TOOL_SCOPE), handleToolInvoke);
app.delete('/mcp/config', requireAuth, requireScope(ADMIN_SCOPE), requireGroup('mcp-admins'), handleAdminConfig);
Key rotation handling. Cognito rotates its signing keys periodically. The rotation is gradual: the new key appears in the JWKS before the old key is removed. Tokens signed with the old key remain verifiable until they expire. The kid-not-found refetch path handles this transparently — when a token arrives signed with a new key that is not yet in the 24-hour cache, your code refetches the JWKS once and finds the new key without dropping valid tokens.
Pattern 3: The OAuth2 flows — PKCE for humans, client credentials for machines, Identity Pools for federated AWS access
MCP server authentication requires two completely different OAuth2 flows depending on who is doing the authenticating. The OAuth2 flows guide covers both end-to-end. The Identity Pools guide covers the extension to AWS service access.
Authorization Code + PKCE for human users. Human users authenticating via Claude Desktop, a browser, or a native app use the Authorization Code flow with PKCE. PKCE (RFC 7636) prevents authorization code interception attacks by binding the authorization request to the token exchange: the client generates a random code_verifier, commits to its hash in the authorization request, and proves knowledge of the verifier at code exchange time.
// PKCE generation — Node.js
const crypto = require('crypto');
function generateCodeVerifier() {
// 43–128 characters from URL-safe base64 alphabet, no padding
return crypto.randomBytes(96).toString('base64url').slice(0, 128);
}
function generateCodeChallenge(verifier) {
// BASE64URL(SHA256(ASCII(code_verifier)))
// Hash the raw bytes, not the hex string; output base64url with no padding
return crypto.createHash('sha256').update(verifier).digest('base64url');
}
// Step 1: redirect user to Cognito Hosted UI
router.get('/login', (req, res) => {
const verifier = generateCodeVerifier();
const challenge = generateCodeChallenge(verifier);
const state = crypto.randomBytes(16).toString('hex');
// Store in server-side session — NEVER expose verifier to the browser
req.session.pkce = { verifier, state };
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid email profile https://api.mcp-server.example.com/tools:invoke',
code_challenge: challenge,
code_challenge_method: 'S256',
state,
});
res.redirect(`${COGNITO_DOMAIN}/oauth2/authorize?${params}`);
});
Four invariants the PKCE exchange must satisfy:
- Single use. Authorization codes are single-use with a 10-minute TTL. A
invalid_granterror on exchange almost always means the code was already used, expired, or the PKCE verifier is wrong — not an auth infrastructure problem. Never retry a failed code exchange; request a new login instead. - Exact redirect URI match. The
redirect_uriin the token exchange must be byte-for-byte identical to the one in the authorization request and in the app client'sCallbackURLs. A trailing slash, protocol difference, or case variation causes HTTP 400redirect_mismatch. - State stored server-side. The CSRF protection state value must survive the round-trip to Cognito and back. In-process memory fails under horizontal scaling and serverless cold starts — store state in Redis or DynamoDB keyed by session ID.
- SHA-256 on raw bytes, not hex. The most common PKCE implementation bug: developers compute
SHA256(code_verifier)as a hex string, then base64url-encode that hex string. Cognito expects the SHA-256 output as raw binary bytes, then base64url-encoded. The results are different and Cognito will reject the challenge withinvalid_request.
// Step 2: handle the callback and exchange code for tokens
router.get('/callback', async (req, res) => {
const { code, state, error } = req.query;
if (error) return res.status(400).json({ error, error_description: req.query.error_description });
const pkce = req.session.pkce;
if (!pkce || pkce.state !== state) {
return res.status(400).json({ error: 'invalid_state', error_description: 'CSRF state mismatch' });
}
const tokenResponse = await fetch(`${COGNITO_DOMAIN}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
code,
code_verifier: pkce.verifier, // proves knowledge of the original verifier
}),
});
// tokens: { access_token, id_token, refresh_token, expires_in, token_type }
const tokens = await tokenResponse.json();
// Store server-side; never expose refresh_token to client JavaScript
req.session.tokens = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + (tokens.expires_in * 1000),
};
delete req.session.pkce;
res.redirect('/');
});
Client Credentials for machine-to-machine. Automated agents — one MCP server calling another, an orchestrator acquiring a service token, a monitoring agent health-checking a protected endpoint — use the Client Credentials flow. No user interaction. No redirect. The confidential app client exchanges its ID and secret directly for an access token. No id_token and no refresh_token are returned — the pattern is stateless: cache the token, re-fetch when it expires.
// M2M token manager: cache and refresh automatically
class CognitoM2MTokenManager {
constructor({ cognitoDomain, clientId, clientSecret, scope }) {
this.tokenEndpoint = `${cognitoDomain}/oauth2/token`;
this.credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
this.scope = scope;
this.cachedToken = null;
this.tokenExpiresAt = 0;
}
async getAccessToken() {
const bufferMs = 60 * 1000; // refresh 60s before expiry
if (this.cachedToken && Date.now() < this.tokenExpiresAt - bufferMs) {
return this.cachedToken;
}
return this.fetchNewToken();
}
async fetchNewToken() {
const response = await fetch(this.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${this.credentials}`, // preferred over body params
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: this.scope,
}),
});
if (!response.ok) {
const err = await response.json();
throw new Error(`M2M token fetch failed: ${err.error} — ${err.error_description}`);
}
const data = await response.json();
this.cachedToken = data.access_token;
this.tokenExpiresAt = Date.now() + (data.expires_in * 1000);
return this.cachedToken;
}
}
A key distinction: OIDC scopes (openid, email, profile) require a user context and cannot be requested in the client credentials flow — Cognito will return an error. Only resource server scopes (e.g., https://api.mcp-server.example.com/tools:invoke) work in M2M token requests.
Identity Pools for federated AWS service access. The user pool authenticates users and issues JWTs. Identity Pools extend this by exchanging those JWTs for temporary AWS STS credentials, enabling agent code to call AWS services directly — S3, DynamoDB, Bedrock — under the user's identity without routing every request through the MCP server as a proxy. The Identity Pools guide covers the full setup.
The exchange is a two-step sequence. Both calls use the Cognito Identity client (not Cognito IDP) and require no pre-existing AWS credentials for the initial GetId call:
// JavaScript SDK: exchange User Pool ID token for temporary AWS credentials
const { CognitoIdentityClient, GetIdCommand, GetCredentialsForIdentityCommand } = require("@aws-sdk/client-cognito-identity");
const REGION = "us-east-1";
const USER_POOL_ID = "us-east-1_AbCdEfGhI";
const IDENTITY_POOL_ID = "us-east-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
const LOGIN_KEY = `cognito-idp.${REGION}.amazonaws.com/${USER_POOL_ID}`;
async function getAWSCredentialsForUser(idToken) {
const cognitoIdentity = new CognitoIdentityClient({ region: REGION });
// Step 1: GetId — returns a stable identity ID for this user (persisted across sessions)
const { IdentityId: identityId } = await cognitoIdentity.send(new GetIdCommand({
AccountId: "123456789012",
IdentityPoolId: IDENTITY_POOL_ID,
Logins: { [LOGIN_KEY]: idToken }, // use the ID token here, NOT the access token
}));
// Step 2: GetCredentialsForIdentity — STS credentials expiring in ~1 hour
const { Credentials } = await cognitoIdentity.send(new GetCredentialsForIdentityCommand({
IdentityId: identityId,
Logins: { [LOGIN_KEY]: idToken },
}));
return {
accessKeyId: Credentials.AccessKeyId,
secretAccessKey: Credentials.SecretKey,
sessionToken: Credentials.SessionToken,
expiration: Credentials.Expiration,
identityId,
};
}
Note the credential exchange requires the ID token (not the access token) in the Logins map. The login key string must exactly match the format cognito-idp.{region}.amazonaws.com/{userPoolId} — a malformed key causes NotAuthorizedException: Invalid login token with no indication of the key format error.
Row-level security with cognito-identity.amazonaws.com:sub. The most powerful use of identity pool credentials is IAM condition variables that resolve to the specific user's identity at AWS service call time. The ${cognito-identity.amazonaws.com:sub} variable equals the Cognito Identity ID — the stable identifier from GetId. This is a different value from the User Pool's sub claim, which is a common source of confusion when debugging access denials.
# IAM permission policy: per-user S3 prefix and DynamoDB row isolation
# ${cognito-identity.amazonaws.com:sub} resolves to the user's Cognito Identity ID at runtime
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowS3UserPrefix",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::mcp-server-user-data/${cognito-identity.amazonaws.com:sub}",
"arn:aws:s3:::mcp-server-user-data/${cognito-identity.amazonaws.com:sub}/*"
]
},
{
"Sid": "AllowDynamoDBUserRows",
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query"],
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/mcp-server-user-context",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": ["${cognito-identity.amazonaws.com:sub}"]
}
}
}
]
}
This isolation is enforced entirely at the IAM layer — no application code is needed to filter by user ID in queries or to validate ownership in put/update operations. A user requesting another user's DynamoDB rows receives AccessDeniedException from DynamoDB itself.
The IAM role trust policy that enables AssumeRoleWithWebIdentity for identity pool users must include two condition keys. Missing either allows the role to be assumed from outside the identity pool context:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "cognito-identity.amazonaws.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"cognito-identity.amazonaws.com:aud": "us-east-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
},
"ForAnyValue:StringLike": {
"cognito-identity.amazonaws.com:amr": "authenticated"
}
}
}]
}
Enhanced vs basic auth flow. The default enhanced flow lets the identity pool automatically select the IAM role from its role mappings — for example, assigning an admin role to users in the mcp-admins Cognito group and the standard authenticated role to everyone else. The basic flow (requires AllowClassicFlow=true on the pool) lets the client specify an exact role ARN in the GetCredentialsForIdentity call, which is necessary for cross-account role assumptions. For most MCP deployments the enhanced flow is the right default.
AliveMCP and Cognito-protected MCP servers
Adding Cognito authentication to your MCP server breaks naive health monitors. A ping to your endpoint from a monitor that doesn't know about Cognito receives HTTP 401, which an unsophisticated uptime checker reports as "server down" — even when the server is healthy and the 401 is the correct response to an unauthenticated probe.
AliveMCP handles Cognito-protected MCP servers at every layer: configure AliveMCP with your confidential app client's credentials, and AliveMCP uses the client credentials flow described in Pattern 3 to obtain a fresh M2M access token before each probe. It attaches the token as a Bearer header on health-check requests and tracks token expiry to refresh before it lapses — so health checks are never rejected due to an expired monitoring token. AliveMCP also distinguishes between a 401 (auth misconfiguration — separate alert) and a 5xx or timeout (genuine server failure), so you get the right alert for the right problem. AliveMCP additionally monitors your Cognito Hosted UI domain's TLS certificate: if the ACM certificate backing your custom auth.mcp-server.example.com domain is about to expire, you get an alert before your login flow breaks — because an expired auth domain TLS certificate is as much an outage as a broken MCP server process.
Consolidated failure modes
| Symptom | Root cause | Fix |
|---|---|---|
NotAuthorizedException: Unable to verify secret hash for client from SPA | App client has a client secret but the browser app does not send the required HMAC hash | Create a new app client with --no-generate-secret; public clients must never have a client secret |
Hosted UI returns HTTP 400 redirect_mismatch after login | redirect_uri does not exactly match a CallbackURL on the app client (trailing slash, protocol, or case difference) | Add the exact URI to the client's CallbackURLs via update-user-pool-client |
invalid_grant at token endpoint when exchanging auth code | Code already used (single-use); code expired (10-minute TTL); code_verifier does not match code_challenge; redirect_uri differs between authorization and token requests | Never retry a failed code exchange — redirect to login for a new code; verify PKCE computes SHA-256 on raw bytes then base64url; ensure redirect_uri is identical in both requests |
PKCE rejected with invalid_request: code_challenge not valid | SHA-256 computed as hex string instead of binary bytes before base64url encoding; or padding characters left in the output | Use crypto.createHash('sha256').update(verifier).digest('base64url') — digest('base64url') outputs binary-derived base64url with no padding |
JWT signature verifies but token_use claim is "id" instead of "access" | Client sends the ID token to the MCP server API instead of the access token | Use tokens.access_token for API calls; middleware must reject tokens where token_use !== "access" |
JsonWebTokenError: secretOrPublicKey must have a value / kid not in cache | Cognito rotated keys since the JWKS cache was last populated; unknown kid in JWT header | On kid-not-found, refetch JWKS once and retry; jwks-rsa handles this automatically |
| High latency on first request after cold start | JWKS fetch adds 50–200ms on cache miss | Pre-warm JWKS cache at server startup via jwks.getSigningKeys() before accepting traffic |
unauthorized_client on client credentials token request | App client does not have client_credentials in AllowedOAuthFlows; or requested scope not in AllowedOAuthScopes; or scope not defined on any resource server | Update client with --allowed-o-auth-flows client_credentials; add scope to both resource server definition and client's allowed scopes |
OIDC scopes (openid, email) rejected in client credentials request | OIDC scopes require user context and are not supported in M2M flows | Remove OIDC scopes from client credentials requests; only request resource server scopes |
NotAuthorizedException: Invalid login token at GetId | Malformed login key string; expired ID token passed; access token sent instead of ID token | Login key must be exactly cognito-idp.{region}.amazonaws.com/{userPoolId}; always pass the ID token (not access token) to identity pool APIs |
AccessDeniedException from S3/DynamoDB with identity pool credentials | IAM role trust policy missing cognito-identity.amazonaws.com:aud condition with correct identity pool ID | Verify trust policy has StringEquals: "cognito-identity.amazonaws.com:aud": "{identityPoolId}" |
| Row-level DynamoDB denial even when user ID matches | ${cognito-identity.amazonaws.com:sub} is the Identity Pool sub, not the User Pool sub — they are different values | Use the Cognito Identity ID (returned by GetId) as the DynamoDB partition key, not the User Pool sub claim |
| Identity pool credentials expire mid-session | STS credentials expire in ~1 hour; no auto-refresh unless configured | Use fromCognitoIdentityPool from @aws-sdk/credential-providers for automatic refresh; or implement credential refresh using stored ID token before expiry |
| Custom domain for Hosted UI returns 404 after setup | CNAME DNS record pointing to CloudFront alias not propagated; ACM certificate issued in wrong region (must be us-east-1 for Cognito custom domains) | Run aws cognito-idp describe-user-pool-domain for the CloudFront alias; check certificate is in us-east-1; allow up to 48h for DNS propagation |