Guide · Authentication

MCP server JWT validation

JWT validation in an MCP server happens exactly once per session — in the HTTP middleware before the MCP initialize message is processed. A token that passes validation at session start is assumed valid for the lifetime of the session; per-tool-call re-validation is both unnecessary and harmful (it re-validates an expiring token against the same cached JWKS, masking actual rotation failures). This guide covers the correct validation pipeline: signature verification via JWKS, algorithm enforcement, required claim checks, custom claim extraction, and the two failure modes to distinguish — expired tokens and invalid signatures — each of which requires a different response.

TL;DR

Use jose's jwtVerify with createRemoteJWKSet. Always pass algorithms, issuer, and audience — omitting any of these turns verification into signature-only, which is exploitable. Extract sub, scope, and any custom claims from the verified payload and store them in res.locals.identity for session binding. Return HTTP 401 for all validation failures — distinguish token_expired vs. invalid_token in the error body so clients can decide whether to refresh or re-authenticate from scratch.

JWT structure and what each part proves

A JWT is three base64url-encoded parts joined by dots: header.payload.signature. The header names the algorithm (alg) and the key ID (kid). The payload carries claims. The signature is a cryptographic binding of header + payload to a private key held by the authorization server.

PartWhat it containsWhat it proves
Headeralg, kid, typWhich algorithm and key to verify with
PayloadClaims: sub, iss, aud, exp, nbf, iat, customWho the token is about, who issued it, who it's for, when it expires
SignatureHMAC or asymmetric sig over header+payloadThe authorization server with the private key produced this token

Validating only the signature is insufficient. A valid signature proves the token was issued by the expected authorization server — it does not prove it was issued for your service (aud), that it is not expired (exp), or that the issuer is who you expect (iss). All four checks are mandatory.

Algorithm choice: RS256 vs ES256

For MCP servers using OAuth 2.0 bearer tokens, use an asymmetric algorithm — RS256 (RSA + SHA-256) or ES256 (ECDSA P-256 + SHA-256). The authorization server signs with its private key; your MCP server verifies with the public key from the JWKS endpoint. Never share the private key.

AlgorithmKey typeSignature sizeWhen to use
RS256RSA 2048–4096 bit256–512 bytesDefault — broad client library support, well-understood
ES256ECDSA P-25664 bytesConstrained environments, smaller tokens, newer auth servers
HS256Shared secret (HMAC)32 bytesNever for MCP — the verification secret must be kept on the auth server, not distributed to every MCP instance

Never allow HS256 in your MCP server's validation. An attacker who knows the algorithm is HMAC can forge tokens by signing their own payload with a shared secret they guessed or extracted. Always restrict to asymmetric algorithms in the algorithms option:

// Restrict to RS256 and ES256 only — never allow HS256 or none
const { payload } = await jwtVerify(token, JWKS, {
  algorithms: ['RS256', 'ES256'],
  issuer: process.env.AUTH_ISSUER,
  audience: process.env.AUTH_AUDIENCE,
});

Full validation middleware with jose

import { createRemoteJWKSet, jwtVerify, errors as JoseErrors } from 'jose';

// Initialise once at module level — not per-request
const JWKS = createRemoteJWKSet(
  new URL(`${process.env.AUTH_ISSUER}/.well-known/jwks.json`)
);

interface McpIdentity {
  sub: string;
  scopes: string[];
  plan?: string;
  tenant_id?: string;
}

async function jwtAuthMiddleware(
  req: express.Request,
  res: express.Response,
  next: express.NextFunction
) {
  const authHeader = req.headers['authorization'];
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({
      error: 'missing_token',
      message: 'Authorization: Bearer  header is required',
    });
  }

  const token = authHeader.slice(7);
  try {
    const { payload } = await jwtVerify(token, JWKS, {
      algorithms: ['RS256', 'ES256'],
      issuer: process.env.AUTH_ISSUER,
      audience: process.env.AUTH_AUDIENCE,
    });

    if (!payload.sub) {
      return res.status(401).json({
        error: 'invalid_token',
        message: 'Token missing required sub claim',
      });
    }

    const identity: McpIdentity = {
      sub: payload.sub,
      scopes: (payload['scope'] as string)?.split(' ') ?? [],
      plan: payload['plan'] as string | undefined,
      tenant_id: payload['tenant_id'] as string | undefined,
    };

    res.locals.identity = identity;
    next();
  } catch (err) {
    if (err instanceof JoseErrors.JWTExpired) {
      // Client can refresh and retry
      return res.status(401).json({
        error: 'token_expired',
        message: 'JWT has expired — refresh your token and reconnect',
      });
    }
    if (err instanceof JoseErrors.JWTClaimValidationFailed) {
      // Wrong issuer or audience — likely misconfiguration or token reuse
      return res.status(401).json({
        error: 'invalid_claims',
        message: 'Token issuer or audience does not match this server',
      });
    }
    // Invalid signature, malformed token, unknown kid
    return res.status(401).json({
      error: 'invalid_token',
      message: 'Token verification failed',
    });
  }
}

The three distinct error responses matter for client behaviour: token_expired means the client should refresh its OAuth token and reconnect; invalid_claims means the client is sending a token issued for a different service and should request a token with the correct audience; invalid_token means the token is corrupt or tampered and the client should start over with a fresh login. A single generic 401 forces clients to treat all three as "start over", which causes unnecessary full re-authentication on token expiry.

Required claim validation checklist

jwtVerify from jose validates iss, aud, exp, and nbf automatically when you pass issuer and audience. What it does not validate automatically — and what you must check yourself:

Claimjose auto-checksWhat to do manually
iss (issuer)Yes — when issuer option is setSet the option; never omit it
aud (audience)Yes — when audience option is setSet the option; never omit it
exp (expiry)Yes — alwaysHandle JWTExpired distinctly
nbf (not before)Yes — alwaysNo extra action needed
sub (subject)NoCheck payload.sub is present and non-empty
scope (permissions)NoParse and store; enforce per-tool in handlers
Custom claimsNoExtract and type-assert after verification

The clock skew default in jose is ±1 minute — tokens within 60 seconds past their exp are still accepted. Increase this tolerance with the clockTolerance option if your MCP server and auth server clocks can drift more than 60 seconds, but never set it above 5 minutes.

JWKS caching and key rotation handling

createRemoteJWKSet caches the JWKS response in memory. The cache is invalidated when a JWT arrives with a kid not present in the cached JWKS — the library re-fetches automatically. This is the correct behaviour for transparent key rotation: the authorization server publishes the new key, the first token signed with it triggers a re-fetch, and all subsequent validations succeed without restart.

What can go wrong: if the authorization server removes the old key from JWKS before all existing sessions have ended, in-flight sessions with tokens signed by the old key will fail validation after the next re-fetch. See MCP server JWKS key rotation for the rotation grace period strategy that prevents this.

// The JWKS instance handles caching — never create it per-request
const JWKS = createRemoteJWKSet(
  new URL(`${process.env.AUTH_ISSUER}/.well-known/jwks.json`),
  {
    cacheMaxAge: 10 * 60 * 1000,   // cache for 10 minutes
    cooldownDuration: 30 * 1000,   // wait 30s before re-fetching on unknown kid
  }
);

The cooldownDuration prevents a flood of re-fetches if an attacker sends many tokens with random kid values. Each unknown kid is a cache miss that makes an outbound HTTP request — without a cooldown, this becomes a vector for exhausting the authorization server's rate limits.

Extracting custom claims for RBAC and multi-tenancy

Authorization servers commonly add non-standard claims to JWTs: plan, tenant_id, org_id, roles. These are available in the verified payload after jwtVerify succeeds. Extract and store them alongside the standard identity:

const { payload } = await jwtVerify(token, JWKS, {
  algorithms: ['RS256', 'ES256'],
  issuer: process.env.AUTH_ISSUER,
  audience: process.env.AUTH_AUDIENCE,
});

// Standard claims
const sub        = payload.sub!;
const scopes     = (payload['scope'] as string)?.split(' ') ?? [];

// Custom claims — auth server namespace these (e.g. https://alivemcp.com/plan)
const plan       = payload['https://alivemcp.com/plan'] as string | undefined;
const tenantId   = payload['https://alivemcp.com/tenant_id'] as string | undefined;
const roles      = (payload['https://alivemcp.com/roles'] as string[]) ?? [];

res.locals.identity = { sub, scopes, plan, tenantId, roles };

Custom claims should use a namespaced key (a URI matching your domain) to avoid collision with standard claims in future JWT spec revisions. In your RBAC enforcement layer, use the extracted roles and scopes to gate tool access per caller.

Monitoring JWT validation failures with AliveMCP

AliveMCP's probes authenticate as a dedicated service account using either a long-lived API key or a client-credentials OAuth token (no expiry issues in either case — the probe token is machine-to-machine, not user-delegated). When the probe receives a 401, AliveMCP logs the exact HTTP status and, for servers that return a structured error body, the error field.

A sustained 401 spike in AliveMCP's probe log that reads token_expired means the probe's OAuth token has expired and needs rotation in the AliveMCP dashboard. A spike reading invalid_token means the JWKS endpoint returned a new key set that does not include the key the probe token was signed with — the authorization server rotated keys without a grace period. Both failure modes are visible in AliveMCP's incident timeline before your users notice them.

Related questions

Should I validate the JWT on every MCP request within a session, or only at session start?

Only at session start (the initialize request and the initial HTTP connection setup). Once the MCP session is established, re-validating on every tool call adds latency, JWKS traffic, and complexity without security benefit — the session was established with valid credentials and the HTTP connection is already authenticated. If a token expires mid-session, the next connection attempt (not the next tool call) will fail with 401. For long-lived sessions, use API keys or short-lived OAuth tokens that your client refreshes before the session starts, not mid-session.

What happens when the JWKS endpoint is unreachable?

If the JWKS endpoint is unreachable and the key is not in the cache, createRemoteJWKSet will throw a network error, and your middleware should return 503 (Service Unavailable) rather than 401. This distinguishes a dependency failure from an auth failure. Add a circuit breaker around the JWKS fetch, and consider caching the last-known JWKS to disk so the server can continue validating tokens during a JWKS endpoint outage — see MCP server circuit breaker for the pattern.

Can I use a symmetric JWT (HS256) for testing?

Yes, in unit tests with a test-only secret. Never in integration tests against a shared environment or in production. For integration tests, run a local OAuth server (e.g. node-oauth2-server or the Keycloak dev container) that issues real asymmetric JWTs — this tests the full validation pipeline, including JWKS fetching and claim checks. A test that mocks JWT validation gives you false confidence because the code paths that actually matter — algorithm enforcement, claim validation, JWKS re-fetch on rotation — are never exercised.

How do I pass the validated identity to tool handlers?

Store it in res.locals.identity in the middleware, then bind it to the MCP session in the route handler. Use a Map<sessionId, identity> keyed by the MCP session ID header, and inject the identity into tool handler context via a server middleware function. Tool handlers read context.identity without re-parsing the JWT. See MCP server authentication for the session-binding pattern.

Further reading