Guide · AWS Security
MCP Server Cognito — JWT validation, user pools, OAuth scopes, app clients
AWS Cognito provides a managed OAuth 2.0 and OpenID Connect identity layer for MCP servers that need authenticated access control. Three mistakes cause the most auth failures: validating the ID token instead of the access token (Cognito issues two tokens after authentication — the ID token contains user profile claims and should be used by client apps to display user information; the access token contains scopes and should be the token your MCP server validates for API authorization; validating the ID token on the server side works but the aud claim in the ID token is the app client ID, not your API — which makes the validation logic inconsistent with standard OAuth2 resource server patterns), skipping iss claim verification (the access token's iss claim must be verified to match your specific user pool URL https://cognito-idp.REGION.amazonaws.com/POOL_ID — without this check, a token issued by a different Cognito user pool would pass signature verification using the same AWS KMS key infrastructure, allowing tokens from other tenants to authorize your API), and not caching the JWKS response (the JSON Web Key Set at /.well-known/jwks.json changes rarely — only when Cognito rotates its signing keys — but fetching it on every request adds 50–200ms latency and will trigger Cognito's rate limits under high load; cache the JWKS in memory for at least 1 hour and refresh when a kid is not found in the cache).
TL;DR
Validate the access token (not the ID token) in your MCP server middleware. Verify: signature against JWKS, iss matches your user pool URL, token_use is "access", exp is in the future, and scope contains the required custom scope. Cache JWKS for 1 hour. Use aws-jwt-verify (AWS's official library) rather than writing verification from scratch.
User pool setup and JWT validation
A Cognito user pool is an OAuth 2.0 authorization server that issues JWTs signed with an RS256 key. The public key is published at the JWKS endpoint. Your MCP server validates incoming tokens by verifying the signature, checking the claims, and extracting the scopes.
// CDK: Cognito user pool with resource server for MCP API scopes
import * as cognito from "aws-cdk-lib/aws-cognito";
const userPool = new cognito.UserPool(this, "McpUserPool", {
userPoolName: "mcp-server-users",
selfSignUpEnabled: true,
signInAliases: { email: true },
passwordPolicy: {
minLength: 12,
requireUppercase: true,
requireDigits: true,
requireSymbols: false,
},
accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
removalPolicy: RemovalPolicy.RETAIN,
});
// Resource server: defines the custom scopes your MCP server accepts
const resourceServer = userPool.addResourceServer("McpApiServer", {
identifier: "https://api.alivemcp.com",
scopes: [
{
scopeName: "tools:read",
scopeDescription: "Call read-only MCP tools",
},
{
scopeName: "tools:write",
scopeDescription: "Call state-modifying MCP tools",
},
{
scopeName: "admin",
scopeDescription: "Full MCP server administration",
},
],
});
// App client: public client for browser/mobile, no secret (PKCE flow)
const appClient = userPool.addClient("McpWebClient", {
userPoolClientName: "mcp-web",
generateSecret: false, // public client — no secret; use PKCE
oAuth: {
flows: { authorizationCodeGrant: true },
scopes: [
cognito.OAuthScope.OPENID,
cognito.OAuthScope.EMAIL,
cognito.OAuthScope.resourceServer(resourceServer, { scopeName: "tools:read", scopeDescription: "Read tools" }),
cognito.OAuthScope.resourceServer(resourceServer, { scopeName: "tools:write", scopeDescription: "Write tools" }),
],
callbackUrls: ["https://alivemcp.com/auth/callback"],
logoutUrls: ["https://alivemcp.com/auth/logout"],
},
accessTokenValidity: Duration.hours(1), // cannot be extended beyond 24h
refreshTokenValidity: Duration.days(30),
});
// Cognito domain for hosted UI
userPool.addDomain("McpDomain", {
cognitoDomain: { domainPrefix: "alivemcp-auth" },
});
Access token validation in MCP server middleware
Use aws-jwt-verify (the official AWS library) to validate Cognito tokens. It handles JWKS fetching, caching, signature verification, and claim validation. Write this once as middleware and apply it to all authenticated MCP routes.
// Node.js: MCP server auth middleware using aws-jwt-verify
import { CognitoJwtVerifier } from "aws-jwt-verify";
const verifier = CognitoJwtVerifier.create({
userPoolId: process.env.COGNITO_USER_POOL_ID!,
tokenUse: "access", // MUST be "access" — do not use "id" for API authorization
clientId: process.env.COGNITO_CLIENT_ID!,
});
// aws-jwt-verify caches JWKS automatically and refreshes on unknown kid
export async function requireAuth(requiredScope: string) {
return async (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing Authorization header" });
}
const token = authHeader.slice(7);
try {
const payload = await verifier.verify(token);
// payload.iss verified to match user pool URL
// payload.token_use verified to be "access"
// payload.exp verified to be in the future
// payload.aud verified to match clientId
// Check custom scope
const scopes = (payload.scope as string)?.split(" ") ?? [];
if (!scopes.includes(`https://api.alivemcp.com/${requiredScope}`)) {
return res.status(403).json({ error: `Scope ${requiredScope} required` });
}
(req as any).cognitoPayload = payload;
next();
} catch (err) {
return res.status(401).json({ error: "Invalid or expired token" });
}
};
}
// Apply to MCP routes
app.use("/mcp", await requireAuth("tools:read"));
app.use("/mcp/write-tools", await requireAuth("tools:write"));
Token expiry: Cognito access tokens expire after 1 hour (minimum 5 minutes, maximum 24 hours — the maximum is fixed and cannot be extended). Clients must use the refresh token to obtain a new access token before expiry. Design your MCP client to handle 401 responses by refreshing the token and retrying once.
Access token vs. ID token
| Token | token_use claim | aud claim | Use for |
|---|---|---|---|
| Access token | "access" | not present (client ID in client_id) | API authorization — validate this on your MCP server |
| ID token | "id" | app client ID | User profile display in client apps — do not validate this on your API |
| Refresh token | — | — | Obtain new access/ID tokens — opaque, not a JWT, never sent to your API |
The token_use: "access" check in aws-jwt-verify (tokenUse: "access") ensures you reject ID tokens presented at your API endpoint. This is critical because some client libraries accidentally send the ID token instead of the access token — a bug that is hard to detect without the claim check.
// Manual claim checks (if not using aws-jwt-verify):
function validateAccessToken(decodedPayload: Record): void {
const poolId = process.env.COGNITO_USER_POOL_ID!;
const region = poolId.split("_")[0];
// 1. iss must match your user pool
const expectedIss = `https://cognito-idp.${region}.amazonaws.com/${poolId}`;
if (decodedPayload.iss !== expectedIss) throw new Error("Invalid issuer");
// 2. token_use must be "access" (not "id")
if (decodedPayload.token_use !== "access") throw new Error("Not an access token");
// 3. exp must be in the future
if ((decodedPayload.exp as number) < Math.floor(Date.now() / 1000)) {
throw new Error("Token expired");
}
// 4. Signature already verified against JWKS by the JWT library
}
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Token validation passes but scope check fails | Custom scopes include the resource server identifier prefix (https://api.alivemcp.com/tools:read), but scope check strips the prefix | Check the full scope string including prefix: scopes.includes("https://api.alivemcp.com/tools:read") |
| Valid token returns 401 with "aud claim mismatch" | Access token does not have an aud claim (it uses client_id); library is checking aud | Configure verifier with clientId not audience; aws-jwt-verify uses clientId for access tokens |
| Latency spike of 200ms on every authenticated request | JWKS is fetched on every request instead of cached | Use aws-jwt-verify which caches JWKS automatically, or implement in-process LRU cache with 1-hour TTL |
| Token from another tenant's user pool passes signature check | iss claim not verified — another Cognito pool's tokens pass because RS256 signature alone doesn't pin to your pool | Always verify iss matches your specific pool URL; use userPoolId in verifier config |
| Client sends token, server returns 401 — token looks valid in jwt.io | Client is sending the ID token instead of the access token | Check token_use claim in the rejected token; instruct client to use the access token from the OAuth response |
| Refresh token expires after 30 days, user permanently logged out | refreshTokenValidity set to 30 days; users inactive for over a month are forced to re-login | Extend refreshTokenValidity up to 3650 days for user-facing apps; implement silent re-auth UX for expired refresh tokens |