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
- JWT verification without algorithm allowlist — HIGH; algorithm confusion attack vector
- jwt.decode() used for authorization (no signature verification) — HIGH; signature bypass
- ignoreExpiration: true in jwt.verify() — HIGH; permanent-credential risk
- No audience validation on multi-tenant or multi-service deployments — WARN; token replay across services
See also
- MCP server authentication — broader authentication patterns beyond JWTs
- MCP server OAuth2 security — OAuth2 flow security including PKCE and state
- MCP server OWASP Top 10 — broken authentication in the full MCP threat model
- Public audit corpus — JWT-related findings across scanned servers
Check your JWT validation for algorithm confusion and expiry findings.
Run a free audit → How grading works →