Topic: mcp server jwt validation

MCP server JWT validation — algorithm confusion, none attack, and expiry checks

JWTs are a common authentication mechanism for MCP servers that expose HTTP transport. A correctly implemented JWT validation is four things at once: algorithm pinning (only accept the algorithm you issued), signature verification (with the right key for that algorithm), claims validation (expiry, audience, issuer), and none-algorithm rejection (explicit allowlist blocks the classic bypass). Missing any one of these four lets an attacker forge tokens — issuing themselves arbitrary claims including admin roles or broad scopes.

Attack 1: Algorithm confusion

The algorithm confusion attack exploits JWT libraries that accept the algorithm from the token's header without restriction. The classic variant: an RS256 token is signed with a private key; the attacker changes the header to "alg": "HS256" and signs the token with the public key as the HMAC secret. A library that picks the algorithm from the header will attempt to verify the token as HS256 using the public key — which is usually available to the attacker — and succeed.

import jwt from 'jsonwebtoken'

// WRONG: algorithm comes from the token header
const decoded = jwt.verify(token, publicKey)
// If the attacker changes header to HS256 and signs with the public key,
// jwt.verify() will attempt HS256 verification using publicKey as the HMAC secret

// CORRECT: explicit algorithm allowlist
const decoded = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],  // only accept RS256 — rejects any other alg header value
})

// For symmetric (HS256) tokens:
const decoded = jwt.verify(token, process.env.JWT_SECRET!, {
  algorithms: ['HS256'],
})

Attack 2: The none algorithm bypass

The JWT specification includes "alg": "none" as a valid header value for tokens that carry no signature. Many early JWT library implementations accepted this value without requiring an explicit opt-in, allowing an attacker to strip the signature from any valid token, change the payload arbitrarily, and present it as valid. The fix is the same as for algorithm confusion: pass an explicit algorithms allowlist that does not include 'none'.

// Attack: original token
// header: {"alg":"RS256","typ":"JWT"}
// payload: {"sub":"user123","role":"user","exp":9999999999}
// signature: [valid RS256 signature]

// Attacker modifies to:
// header: {"alg":"none","typ":"JWT"}
// payload: {"sub":"user123","role":"admin","exp":9999999999}
// signature: [empty — stripped]

// With explicit algorithms allowlist, 'none' header is rejected:
const decoded = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],  // 'none' not in list → Error: invalid algorithm
})

// With jose library (preferred for modern MCP servers):
import { jwtVerify, createRemoteJWKSet } from 'jose'

const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'))

const { payload } = await jwtVerify(token, JWKS, {
  issuer: 'https://auth.example.com/',
  audience: 'https://skillaudit.dev/api/',
  // jose rejects 'none' algorithm by default — no explicit config needed
  // algorithms are determined from the JWKS key types
})

Attack 3: Missing expiry validation

JWT expiry (exp claim) ensures that a token stolen from a session, log file, or tool response has a bounded window of utility. A missing or disabled expiry check turns every token into a permanent credential. For MCP servers, expiry validation is especially important because tokens may appear in LLM context windows, tool call logs, or prompt-injection exfiltration attempts.

// WRONG: ignoring expiry
const decoded = jwt.verify(token, secret, {
  algorithms: ['HS256'],
  ignoreExpiration: true,  // ← never do this in production
})

// WRONG: manual decode without verification (no expiry check)
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString())
// This skips signature verification AND expiry — never use for auth

// CORRECT: expiry is validated by default in jwt.verify()
// Add clock skew tolerance (default is 0 in jsonwebtoken)
const decoded = jwt.verify(token, secret, {
  algorithms: ['HS256'],
  clockTolerance: 30,  // allow 30 seconds of clock skew
  // exp is checked by default — throws TokenExpiredError if expired
})

// Handle the error cases separately
try {
  const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] })
  return decoded
} catch (err) {
  if (err instanceof jwt.TokenExpiredError) {
    // Token expired — prompt user to re-authenticate
    throw new McpError(ErrorCode.InvalidRequest, 'Token expired — please re-authenticate')
  }
  if (err instanceof jwt.JsonWebTokenError) {
    // Invalid signature or malformed token
    throw new McpError(ErrorCode.InvalidRequest, 'Invalid token')
  }
  throw err
}

Attack 4: Missing audience validation

The aud (audience) claim identifies the intended recipients of a JWT. Without audience validation, a token issued for a different service (e.g., your analytics API) is accepted as valid by your MCP server — even if the signature is valid and the token hasn't expired. An attacker who obtains a token for service A can replay it at service B if service B doesn't check the audience.

// WRONG: no audience validation
const decoded = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  // aud not validated — token for 'https://analytics.example.com/' is accepted here
})

// CORRECT: require specific audience
const decoded = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  audience: 'https://mcp.skillaudit.dev/',  // must match aud claim in token
  issuer: 'https://auth.skillaudit.dev/',   // validate issuer too
  // throws JsonWebTokenError if aud or iss don't match
})

// When issuing tokens (on your auth server), always set aud:
const token = jwt.sign(
  {
    sub: userId,
    role: userRole,
    // standard claims:
    iss: 'https://auth.skillaudit.dev/',
    aud: 'https://mcp.skillaudit.dev/',
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 3600,  // 1 hour
  },
  privateKey,
  { algorithm: 'RS256' }
)

What SkillAudit checks

See also

Check your JWT validation for algorithm confusion and expiry findings.

Run a free audit → How grading works →