Guide · AWS Cognito · JWT Verification
Verifying Cognito JWT Tokens in MCP Servers — Signature Validation, Claims Checking, and Middleware
Every MCP server request authenticated with Cognito arrives with a JWT access token in the Authorization: Bearer header. Verifying that token requires three steps: fetch the JWKS (JSON Web Key Set) from Cognito's well-known endpoint to get the public RSA keys, verify the token's RS256 signature against the key matching the token's kid header, and validate the claims (iss, token_use, exp, client_id, scopes). Done correctly — with JWKS caching and proper key rotation handling — verification adds less than 1ms to each request with zero network calls on the hot path. This guide provides complete Node.js middleware, explains the caching strategy to avoid thundering-herd fetches to Cognito's JWKS endpoint, and documents how to handle key rotation without dropping valid tokens. For setting up the user pool that issues these tokens see Cognito User Pools for MCP Servers. For the OAuth2 flows that deliver tokens to clients see OAuth2 and OIDC Flows for MCP Servers.
TL;DR
Fetch JWKS from https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json, cache the keys in memory with a 24-hour TTL. On each request: decode the JWT header to get kid, look up the matching key in cache, verify the RS256 signature, then check iss, token_use == "access", exp > now, and client_id. If kid is not in cache, refetch JWKS once (Cognito may have rotated keys). Return 401 on any verification failure. For adding custom claims to the JWT see Cognito Lambda Triggers.
JWKS endpoint
Cognito publishes its RSA public keys at a well-known URL. This endpoint returns all currently active signing keys. Cognito maintains at least two keys at a time to support key rotation: the current active key and at least one previous key (so tokens signed with the old key remain valid during the rotation window).
# JWKS endpoint URL pattern
# https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json
REGION="us-east-1"
USER_POOL_ID="us-east-1_AbCdEfGhI"
JWKS_URL="https://cognito-idp.${REGION}.amazonaws.com/${USER_POOL_ID}/.well-known/jwks.json"
# Fetch the JWKS manually to inspect the keys
curl -s "$JWKS_URL" | jq .
# Output:
# {
# "keys": [
# {
# "alg": "RS256",
# "e": "AQAB",
# "kid": "abcdefghij1234567890=",
# "kty": "RSA",
# "n": "very-long-base64url-encoded-modulus...",
# "use": "sig"
# },
# {
# "alg": "RS256",
# "e": "AQAB",
# "kid": "zyxwvutsrq0987654321=",
# "kty": "RSA",
# "n": "another-long-base64url-encoded-modulus...",
# "use": "sig"
# }
# ]
# }
# The OIDC discovery document (also useful for verifying issuer metadata)
OPENID_CONFIG_URL="https://cognito-idp.${REGION}.amazonaws.com/${USER_POOL_ID}/.well-known/openid-configuration"
curl -s "$OPENID_CONFIG_URL" | jq '{issuer, jwks_uri, token_endpoint, authorization_endpoint}'
Never hardcode the public key values from the JWKS endpoint. Cognito rotates its signing keys periodically, and hardcoded keys will cause all token verifications to fail after a rotation. Always fetch the JWKS dynamically and cache with a TTL.
ID token vs access token
Cognito issues two short-lived tokens that your MCP server may encounter. Knowing which one to use for which purpose — and how their claims differ — is essential for correct authorization.
| Claim / property | Access token | ID token |
|---|---|---|
token_use | "access" | "id" |
| Audience claim | client_id field (not aud) | aud field = client ID |
| Subject | sub = user's UUID | sub = user's UUID |
| Scopes | scope claim contains granted scopes | No scope claim |
| User attributes | None — no email, name, etc. | email, name, custom:* attributes |
| Groups | cognito:groups array | cognito:groups array |
| Intended use | API authorization — send in Authorization: Bearer | User identity — display name, email, profile data |
| Should your MCP server accept it for auth? | Yes — verify and authorize | No — reject if token_use != "access" |
Node.js verification middleware
The following middleware implements the complete verification pipeline for an Express-based MCP server. It uses jwks-rsa for JWKS caching with automatic retry on key-not-found, and jsonwebtoken for RS256 signature verification and claims validation.
// Install dependencies:
// npm install jsonwebtoken jwks-rsa
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
// Configuration — set these from environment variables
const REGION = process.env.AWS_REGION || 'us-east-1';
const USER_POOL_ID = process.env.COGNITO_USER_POOL_ID; // e.g. "us-east-1_AbCdEfGhI"
const CLIENT_ID = process.env.COGNITO_CLIENT_ID; // your app client ID
const ISSUER = `https://cognito-idp.${REGION}.amazonaws.com/${USER_POOL_ID}`;
// jwks-rsa client with caching (cache=true, 10min rate limit, 24h jwks TTL)
const jwks = jwksClient({
jwksUri: `${ISSUER}/.well-known/jwks.json`,
cache: true,
cacheMaxEntries: 10,
cacheMaxAge: 24 * 60 * 60 * 1000, // 24 hours in milliseconds
rateLimit: true,
jwksRequestsPerMinute: 10,
});
// Retrieve the signing key matching the token's kid
function getSigningKey(header, callback) {
jwks.getSigningKey(header.kid, (err, key) => {
if (err) {
// kid not found — could be key rotation; jwks-rsa will retry with fresh fetch
return callback(err);
}
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
}
// Express middleware: verify Cognito access token
function requireAuth(req, res, next) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: 'unauthorized',
error_description: 'Missing or malformed Authorization header',
www_authenticate: 'Bearer realm="mcp-server"',
});
}
const token = authHeader.slice(7); // strip "Bearer "
const verifyOptions = {
algorithms: ['RS256'], // never allow "none" or symmetric algorithms
issuer: ISSUER, // iss claim must match exactly
// Do not set audience here for access tokens — Cognito puts client_id in
// a separate claim, not aud. We validate client_id manually below.
};
jwt.verify(token, getSigningKey, verifyOptions, (err, decoded) => {
if (err) {
const statusCode = err.name === 'TokenExpiredError' ? 401 : 403;
return res.status(statusCode)
.set('WWW-Authenticate', `Bearer error="${err.name}", error_description="${err.message}"`)
.json({
error: 'invalid_token',
error_description: err.message,
});
}
// Manually validate claims that jsonwebtoken doesn't check automatically
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: 'Token was issued for a different client',
});
}
// Attach decoded claims to request for use in route handlers
req.auth = decoded;
req.userId = decoded.sub;
req.userGroups = decoded['cognito:groups'] || [];
req.tokenScopes = (decoded.scope || '').split(' ');
next();
});
}
module.exports = { requireAuth };
// Usage in your MCP server routes
const express = require('express');
const { requireAuth } = require('./auth-middleware');
const app = express();
app.use(express.json());
// Apply to all routes
app.use(requireAuth);
// Or apply to specific routes
app.post('/mcp/tools/invoke', requireAuth, (req, res) => {
// req.auth contains the verified JWT claims
const userId = req.userId;
const scopes = req.tokenScopes;
// Check for required scope
if (!scopes.includes('https://api.mcp-server.example.com/tools:invoke')) {
return res.status(403).json({
error: 'insufficient_scope',
error_description: 'Required scope: tools:invoke',
});
}
// Check user group for admin operations
if (req.body.toolName === 'admin_reset' && !req.userGroups.includes('mcp-admins')) {
return res.status(403).json({
error: 'forbidden',
error_description: 'Admin group membership required',
});
}
// Proceed with tool invocation
res.json({ result: 'tool invoked', userId });
});
Manual JWKS caching (without jwks-rsa)
If you prefer not to use the jwks-rsa library, or are writing verification in another language, here is the caching logic implemented from first principles. The key behavior to replicate: cache the full JWKS keyset, index by kid, and on a cache miss (unknown kid) refetch once — never loop.
// Manual JWKS cache implementation (language-agnostic logic, shown in Node.js)
const https = require('https');
const crypto = require('crypto');
const JWKS_CACHE = {
keys: {}, // kid -> PEM public key string
fetchedAt: 0, // epoch ms of last successful fetch
ttlMs: 24 * 60 * 60 * 1000, // 24 hour TTL
};
async function fetchJwks(jwksUri) {
return new Promise((resolve, reject) => {
https.get(jwksUri, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch (e) { reject(e); }
});
}).on('error', reject);
});
}
function jwkToPem(jwk) {
// Convert JWK RSA public key to PEM using Node.js crypto
const keyObject = crypto.createPublicKey({ key: jwk, format: 'jwk' });
return keyObject.export({ type: 'spki', format: 'pem' });
}
async function getPublicKey(kid, jwksUri, forceRefetch = false) {
const now = Date.now();
const cacheStale = (now - JWKS_CACHE.fetchedAt) > JWKS_CACHE.ttlMs;
if (forceRefetch || cacheStale || !JWKS_CACHE.keys[kid]) {
const jwksResponse = await fetchJwks(jwksUri);
JWKS_CACHE.keys = {};
for (const key of jwksResponse.keys) {
if (key.use === 'sig' && key.alg === 'RS256') {
JWKS_CACHE.keys[key.kid] = jwkToPem(key);
}
}
JWKS_CACHE.fetchedAt = now;
}
const pem = JWKS_CACHE.keys[kid];
if (!pem) {
if (!forceRefetch) {
// kid not found after fresh fetch — key genuinely does not exist
return getPublicKey(kid, jwksUri, true);
}
throw new Error(`No public key found for kid: ${kid}`);
}
return pem;
}
// Usage:
async function verifyToken(token, jwksUri, issuer, clientId) {
// Decode header without verifying (to extract kid)
const [headerB64] = token.split('.');
const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString());
if (header.alg !== 'RS256') {
throw new Error('Invalid algorithm: only RS256 accepted');
}
const publicKey = await getPublicKey(header.kid, jwksUri);
// Verify with your preferred JWT library
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer,
});
if (decoded.token_use !== 'access') throw new Error('Not an access token');
if (decoded.client_id !== clientId) throw new Error('Wrong client_id');
return decoded;
}
Key rotation handling
Cognito rotates its signing keys periodically. The rotation is gradual: Cognito adds the new key to the JWKS before removing the old key, so there is a window where both keys are valid. Tokens signed with the old key remain verifiable until they expire naturally.
The correct handling is:
- Cache the JWKS with a 24-hour TTL — long enough to avoid hammering Cognito's endpoint, short enough to pick up new keys well before the old key is removed.
- On a verification failure due to
kidnot found in cache: refetch the JWKS immediately and retry once. If the key still is not present, the token is genuinely invalid. - Never hardcode key values in source code or configuration. A hardcoded key breaks silently at the next rotation with no error until a deploy happens.
# Verify the JWKS endpoint returns multiple keys (showing key rotation in progress)
curl -s "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_AbCdEfGhI/.well-known/jwks.json" \
| jq '.keys | length'
# 2 or more keys = rotation in progress or standard operation
# 1 key = only current key (no pending rotation)
# Keys in the JWKS have the following structure:
# kty: "RSA" (key type)
# alg: "RS256" (algorithm)
# use: "sig" (use=signature; Cognito also has "enc" keys for encryption)
# kid: unique key identifier referenced in JWT header
# n: base64url-encoded modulus
# e: base64url-encoded exponent (almost always "AQAB" = 65537)
# When implementing key rotation resilience, only process keys where use=="sig"
# Ignore any keys where use=="enc" — those are for encryption, not signature verification
Clock skew and expiry handling
JWT expiry (exp) is a Unix timestamp in seconds. If the MCP server's system clock is slightly ahead of Cognito's clock, a freshly issued token might appear expired. Standard practice is to allow a small clock skew tolerance — no more than 5 minutes, but 30 seconds is more appropriate for production systems that use NTP.
// Adding clock skew tolerance with jsonwebtoken
jwt.verify(token, signingKey, {
algorithms: ['RS256'],
issuer: ISSUER,
clockTolerance: 30, // Accept tokens up to 30 seconds past their exp
});
// Manual clock skew check if verifying without a library
function isTokenExpired(decodedPayload, clockToleranceSeconds = 30) {
const nowSeconds = Math.floor(Date.now() / 1000);
return decodedPayload.exp < (nowSeconds - clockToleranceSeconds);
}
// Check "not before" (nbf) claim if present — token must not be used before this time
function isTokenNotYetValid(decodedPayload, clockToleranceSeconds = 30) {
if (!decodedPayload.nbf) return false;
const nowSeconds = Math.floor(Date.now() / 1000);
return decodedPayload.nbf > (nowSeconds + clockToleranceSeconds);
}
// Complete claims validation function
function validateClaims(decoded, expectedIssuer, expectedClientId) {
const errors = [];
if (decoded.iss !== expectedIssuer) {
errors.push(`iss mismatch: expected ${expectedIssuer}, got ${decoded.iss}`);
}
if (decoded.token_use !== 'access') {
errors.push(`token_use must be "access", got "${decoded.token_use}"`);
}
if (decoded.client_id !== expectedClientId) {
errors.push(`client_id mismatch: expected ${expectedClientId}`);
}
if (isTokenExpired(decoded)) {
errors.push('Token has expired');
}
if (isTokenNotYetValid(decoded)) {
errors.push('Token is not yet valid (nbf in future)');
}
if (errors.length > 0) {
throw new Error(`Token validation failed: ${errors.join('; ')}`);
}
return true;
}
Scope-based authorization
After verifying the token, use the scope claim for fine-grained API authorization. The scope claim in Cognito access tokens is a space-separated string of all granted scopes. Your MCP server should define which scope each tool endpoint requires and check accordingly.
// Scope authorization helper
function requireScope(requiredScope) {
return function scopeMiddleware(req, res, next) {
const grantedScopes = (req.auth.scope || '').split(' ');
if (!grantedScopes.includes(requiredScope)) {
return res.status(403)
.set('WWW-Authenticate', `Bearer error="insufficient_scope", scope="${requiredScope}"`)
.json({
error: 'insufficient_scope',
error_description: `Required scope: ${requiredScope}`,
scope: requiredScope,
});
}
next();
};
}
// Usage:
const TOOL_SCOPE = 'https://api.mcp-server.example.com/tools:invoke';
const READ_SCOPE = 'https://api.mcp-server.example.com/tools:read';
const ADMIN_SCOPE = 'https://api.mcp-server.example.com/admin';
app.post('/mcp/tools/invoke',
requireAuth,
requireScope(TOOL_SCOPE),
handleToolInvoke
);
app.get('/mcp/tools',
requireAuth,
requireScope(READ_SCOPE),
handleListTools
);
app.delete('/mcp/config',
requireAuth,
requireScope(ADMIN_SCOPE),
handleAdminConfig
);
Group-based authorization
Cognito groups let you assign users to named groups (e.g., mcp-admins, plan-pro) and include the group memberships in the JWT via the cognito:groups claim. Groups are a coarse-grained authorization mechanism — use scopes for what a token is allowed to do, use groups for who the user is.
# Create user groups in the pool
aws cognito-idp create-group \
--user-pool-id $USER_POOL_ID \
--group-name mcp-admins \
--description "MCP server administrators" \
--precedence 1
aws cognito-idp create-group \
--user-pool-id $USER_POOL_ID \
--group-name plan-pro \
--description "Pro plan subscribers" \
--precedence 10
aws cognito-idp create-group \
--user-pool-id $USER_POOL_ID \
--group-name plan-free \
--description "Free plan users" \
--precedence 20
# Add a user to a group
aws cognito-idp admin-add-user-to-group \
--user-pool-id $USER_POOL_ID \
--username user@example.com \
--group-name plan-pro
# List groups a user belongs to
aws cognito-idp admin-list-groups-for-user \
--user-pool-id $USER_POOL_ID \
--username user@example.com
// Group-based middleware for MCP server routes
function requireGroup(groupName) {
return function groupMiddleware(req, res, next) {
const groups = req.auth['cognito:groups'] || [];
if (!groups.includes(groupName)) {
return res.status(403).json({
error: 'forbidden',
error_description: `Group membership required: ${groupName}`,
});
}
next();
};
}
// Rate limit based on plan tier (derived from Cognito group membership)
function getRateLimitForUser(groups) {
if (groups.includes('plan-pro')) return { requests: 1000, windowMs: 60000 };
if (groups.includes('plan-free')) return { requests: 100, windowMs: 60000 };
return { requests: 10, windowMs: 60000 }; // default / unknown group
}
AliveMCP and authenticated MCP server health checks
Once your MCP server requires a valid Cognito access token, naive health checks that hit your endpoint without a token will receive 401 responses — which an unsophisticated monitor would interpret as the server being down. AliveMCP is built to handle authenticated MCP servers: configure AliveMCP with a machine-to-machine access token obtained via Cognito's client credentials flow (using a dedicated monitoring app client), and AliveMCP attaches it as a Bearer token on every health check probe. AliveMCP tracks the token's expiry and refreshes before it lapses, so you never get false-positive "downtime" alerts caused by a stale health-check token.
AliveMCP also differentiates between a 401 response (auth misconfiguration) and a 5xx or connection timeout (genuine server failure) — so you get the right alert for the right problem without adjusting your server's authentication to accommodate monitoring.
Failure modes reference
| Symptom | Cause | Fix |
|---|---|---|
JsonWebTokenError: invalid signature | Token was tampered with; wrong user pool's public key used; token from a different region or pool | Verify the JWKS URL matches the user pool that issued the token; check the iss claim in the token against your JWKS URL |
JsonWebTokenError: secretOrPublicKey must have a value / kid not found | The kid in the token header is not present in the cached JWKS; Cognito has rotated keys since the cache was last populated | On kid-not-found, refetch the JWKS once and retry; if still not found, the token is invalid; never silently ignore a missing kid |
TokenExpiredError: jwt expired | Access token has passed its exp time; client is not refreshing tokens | Return HTTP 401 with WWW-Authenticate: Bearer error="invalid_token"; client should use its refresh token to obtain a new access token; check clock skew if tokens expire immediately after issuance |
| Tokens verify successfully but authorization checks fail with "insufficient_scope" | The app client was not configured with the required scope in AllowedOAuthScopes; or the user authorized with a scope request that did not include the needed scope | Check app client configuration with describe-user-pool-client; ensure the resource server scope is listed in AllowedOAuthScopes; see User Pools guide |
token_use is "id" instead of "access" on API requests | Client code is sending the ID token in the Authorization header instead of the access token | Fix client code to use token_response.access_token for API calls; update middleware to explicitly reject tokens where token_use !== "access" |
| High latency on first request after cold start | JWKS fetch adds 50-200ms on cache miss; cold start with no cached keys requires a network round-trip to Cognito before serving the first request | Pre-warm the JWKS cache at server startup by calling getSigningKey or fetching the JWKS URL during initialization — not in the request handler |