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

Tokentoken_use claimaud claimUse for
Access token"access"not present (client ID in client_id)API authorization — validate this on your MCP server
ID token"id"app client IDUser profile display in client apps — do not validate this on your API
Refresh tokenObtain 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

SymptomCauseFix
Token validation passes but scope check failsCustom scopes include the resource server identifier prefix (https://api.alivemcp.com/tools:read), but scope check strips the prefixCheck 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 audConfigure verifier with clientId not audience; aws-jwt-verify uses clientId for access tokens
Latency spike of 200ms on every authenticated requestJWKS is fetched on every request instead of cachedUse 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 checkiss claim not verified — another Cognito pool's tokens pass because RS256 signature alone doesn't pin to your poolAlways verify iss matches your specific pool URL; use userPoolId in verifier config
Client sends token, server returns 401 — token looks valid in jwt.ioClient is sending the ID token instead of the access tokenCheck 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 outrefreshTokenValidity set to 30 days; users inactive for over a month are forced to re-loginExtend refreshTokenValidity up to 3650 days for user-facing apps; implement silent re-auth UX for expired refresh tokens