MCP Server Security — JSON Web Key Sets
MCP server JSON Web Key Sets security — JWKS endpoint protection, key rotation without downtime, JWKS poisoning via DNS hijack, and public key confusion attacks
JSON Web Key Sets (JWKS) are the public key infrastructure for JWT-based MCP server authentication. When an MCP server verifies a JWT issued by an OAuth provider, it fetches the provider's JWKS to validate the signature. Four attack surfaces arise: an attacker who can influence the JWKS URL (SSRF, open redirect, DNS hijack) can serve malicious keys and forge valid tokens; the alg:none attack skips signature verification entirely if the server doesn't explicitly allowlist algorithms; RS256/HS256 key confusion tricks the server into verifying an RS256 token as HS256 using the public key as the HMAC secret; and missing key overlap during rotation causes downtime when old tokens are verified against a JWKS that no longer contains the signing key. This page covers all four.
Attack 1: JWKS poisoning via SSRF or DNS hijack — forging valid JWT signatures
JWKS poisoning works by replacing the legitimate key set with an attacker-controlled one. The attacker generates their own RSA key pair, publishes the public key at a URL they control, and crafts a JWT signed with the corresponding private key. If the MCP server fetches JWKS from a URL derived from the token's iss (issuer) claim without strict allowlist validation, the attacker can set iss to their own domain, causing the server to fetch keys from attacker-controlled infrastructure and accept their forged token.
// INSECURE: constructing JWKS URL from token iss claim
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const token = req.headers.authorization.replace('Bearer ', '');
const decoded = jwt.decode(token, { complete: true }); // no verification yet
const issuer = decoded.payload.iss;
// WRONG: fetching JWKS from attacker-controlled URL
const client = jwksClient({ jwksUri: `${issuer}/.well-known/jwks.json` });
// -----------------------------------------------------------------------
// SECURE: strict issuer allowlist + pre-configured JWKS URIs
const TRUSTED_ISSUERS = new Map([
['https://auth.example.com', 'https://auth.example.com/.well-known/jwks.json'],
['https://accounts.google.com', 'https://www.googleapis.com/oauth2/v3/certs'],
]);
// Create clients at startup, not per-request
const jwksClients = new Map();
for (const [issuer, jwksUri] of TRUSTED_ISSUERS) {
jwksClients.set(issuer, jwksClient({
jwksUri,
cache: true,
cacheMaxAge: 5 * 60 * 1000, // 5 minute cache
rateLimit: true,
jwksRequestsPerMinute: 10, // prevent JWKS fetch DoS
}));
}
function getJwksClient(issuer) {
const client = jwksClients.get(issuer);
if (!client) throw new Error(`Untrusted issuer: ${issuer}`);
return client;
}
async function verifyToken(token) {
const decoded = jwt.decode(token, { complete: true });
if (!decoded) throw new Error('Invalid JWT');
const { iss, kid } = { ...decoded.payload, ...decoded.header };
const client = getJwksClient(iss); // throws on unknown issuer
const key = await client.getSigningKey(kid);
const publicKey = key.getPublicKey();
// CRITICAL: explicitly specify algorithms — never allow 'none' or symmetric algos
return jwt.verify(token, publicKey, {
algorithms: ['RS256', 'ES256'], // allowlist only asymmetric algorithms
issuer: iss,
audience: process.env.JWT_AUDIENCE,
});
}
Attack 2: alg:none bypass — disabling signature verification via the algorithm header
JWT libraries that trust the alg header from the token accept {"alg":"none"} as a valid algorithm, meaning "unsigned token, skip verification." An attacker who knows this forge tokens by setting alg to none and dropping the signature portion of the JWT. Many early JWT library versions (node-jsonwebtoken before 4.x, PyJWT before 1.x, others) accepted this by default. Modern libraries fixed this but the vulnerability resurfaces when developers disable algorithm enforcement via configuration flags.
// Common mistake: passing a public key with no algorithm restriction
// This allows alg:none bypass in some library versions
// WRONG — algorithm not restricted:
const payload = jwt.verify(token, publicKey);
// WRONG — passing an allowlist that includes 'none':
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256', 'none'] });
// WRONG — trusting alg header by using 'auto' (some libraries support this):
const payload = jwt.verify(token, publicKey, { algorithms: 'auto' });
// -----------------------------------------------------------------------
// CORRECT — explicit, strict algorithm allowlist:
const ALLOWED_ALGORITHMS = ['RS256', 'ES256']; // no 'none', no HS256
function verifyJwt(token, publicKey) {
// jsonwebtoken v9+ throws on alg:none if not in algorithms list
return jwt.verify(token, publicKey, {
algorithms: ALLOWED_ALGORITHMS,
complete: false,
});
}
// Additional defense: check the alg header before calling verify
function preflightJwt(token) {
const [headerB64] = token.split('.');
const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString());
if (!ALLOWED_ALGORITHMS.includes(header.alg)) {
throw new Error(`Rejected: unsupported algorithm in JWT header: ${header.alg}`);
}
return header;
}
Attack 3: RS256/HS256 key confusion — using the public key as HMAC secret
RS256 tokens are signed with an RSA private key and verified with the corresponding public key. HS256 tokens are signed and verified with the same shared secret. The key confusion attack: an attacker obtains your server's RS256 public key (which is public — it's in the JWKS endpoint), signs a new token with HS256 using that public key as the HMAC secret, and sends it to the server. If the server calls jwt.verify(token, publicKey) without specifying the expected algorithm, and if the library sees an HS256 token, it uses publicKey as the HMAC secret — which is exactly what the attacker used to sign. Verification passes. The attacker has forged a token without the private key.
// The RS256/HS256 confusion attack path:
//
// 1. Attacker reads your JWKS endpoint, extracts the RSA public key in PEM format
// 2. Attacker creates a JWT with header {"alg":"HS256"} and any payload they want
// 3. Attacker signs it with HS256, using your PEM public key as the HMAC secret
// 4. Attacker sends this token to your API
//
// If your server calls: jwt.verify(token, pemPublicKey)
// The library sees alg:HS256, uses pemPublicKey as the HMAC secret,
// and the verification succeeds because the attacker used the same key.
// DEFENSE: key type awareness — reject tokens whose alg doesn't match key type
import { createPublicKey } from 'node:crypto';
function verifyWithKeyTypeCheck(token, publicKeyPem) {
// First: check what algorithm the token claims
const [headerB64] = token.split('.');
const { alg, kid } = JSON.parse(Buffer.from(headerB64, 'base64url').toString());
// Determine expected algorithm from key type
const keyObject = createPublicKey(publicKeyPem);
const keyType = keyObject.asymmetricKeyType; // 'rsa', 'ec', 'ed25519', etc.
const EXPECTED_ALG_BY_KEYTYPE = {
rsa: ['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512'],
ec: ['ES256', 'ES384', 'ES512'],
ed25519: ['EdDSA'],
};
const allowedAlgs = EXPECTED_ALG_BY_KEYTYPE[keyType];
if (!allowedAlgs) throw new Error(`Unexpected key type: ${keyType}`);
if (!allowedAlgs.includes(alg)) {
throw new Error(
`Key confusion attack detected: token claims alg=${alg} but key is ${keyType} (expected ${allowedAlgs.join('/')})`
);
}
// Now verify with the enforced algorithm list
return jwt.verify(token, publicKeyPem, { algorithms: allowedAlgs });
}
Attack 4: Missing key overlap during rotation — in-flight tokens fail verification
Key rotation requires a careful sequence: add the new key to JWKS → wait for all verifiers to cache the new JWKS → start issuing tokens with the new kid → wait for max token TTL → remove the old key. Teams that skip steps or shorten the overlap window cause live token verification failures. The new JWKS is cached, the old key is gone, but old tokens (still within their TTL) present a kid that the JWKS no longer contains — verification fails with "no matching key for kid."
// Key rotation runbook implemented as a state machine
// Run these steps sequentially with the indicated wait windows
import { SecretsManagerClient, GetSecretValueCommand, PutSecretValueCommand } from '@aws-sdk/client-secrets-manager';
const sm = new SecretsManagerClient({ region: process.env.AWS_REGION });
const JWKS_CACHE_TTL_MS = 5 * 60 * 1000; // how long verifiers cache JWKS (5 min)
const ACCESS_TOKEN_TTL_MS = 15 * 60 * 1000; // access token lifetime (15 min)
async function rotateSigningKey() {
// Step 1: Generate new key pair
const { privateKey: newPrivate, publicKey: newPublic } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const newKid = crypto.randomUUID();
// Step 2: Add new key to JWKS (keep old key — both keys are valid during overlap)
const currentJwks = await fetchCurrentJwks();
const newJwks = {
keys: [
...currentJwks.keys, // old key(s) remain
await pemToJwk(newPublic, newKid), // new key added
],
};
await updateJwks(newJwks);
console.log('Step 2 done: new key added to JWKS. Waiting for cache propagation...');
// Step 3: Wait for all verifiers to cache the new JWKS
await sleep(JWKS_CACHE_TTL_MS + 30_000); // cache TTL + 30s buffer
// Step 4: Start issuing tokens with new kid
await setActiveSigningKey(newKid, newPrivate);
console.log('Step 4 done: new kid is now active for signing. Waiting for old tokens to expire...');
// Step 5: Wait for max token TTL (all tokens signed with old kid are now expired)
await sleep(ACCESS_TOKEN_TTL_MS + 60_000); // token TTL + 60s buffer
// Step 6: Remove old key from JWKS
const oldKid = currentJwks.keys[0].kid;
const finalJwks = { keys: newJwks.keys.filter(k => k.kid !== oldKid) };
await updateJwks(finalJwks);
console.log('Step 6 done: old key removed from JWKS. Rotation complete.');
}
// getSigningKey in the JWT verifier: try current JWKS cache, on kid-not-found re-fetch
async function getSigningKeyWithRefresh(kid) {
try {
return await jwksClient.getSigningKey(kid);
} catch (err) {
if (err.name === 'SigningKeyNotFoundError') {
// Force a JWKS re-fetch in case we have a stale cache during rotation
await jwksClient.cache.clear();
return await jwksClient.getSigningKey(kid);
}
throw err;
}
}
SkillAudit findings
The following findings appear in SkillAudit audit reports for MCP servers using JWKS-based token verification:
CRITICAL JWKS URL derived from token iss claim without allowlist — JWKS poisoning possible. The server constructs the JWKS URL directly from the iss field in the incoming JWT. An attacker who presents a token with a manipulated iss pointing to attacker-controlled infrastructure can serve forged keys and have any payload accepted as valid.
CRITICAL Algorithm not explicitly restricted — alg:none bypass and RS256/HS256 key confusion possible. JWT verification calls verify(token, key) without an algorithms restriction. An attacker can present a token claiming alg:none (skipping signature verification) or alg:HS256 with the public key as the HMAC secret (key confusion). Always specify algorithms: ['RS256'] or the exact algorithm set explicitly.
HIGH No JWKS cache re-fetch on kid-not-found — token verification fails unnecessarily after rotation. When the JWKS cache doesn't contain the kid from an incoming token, the server immediately rejects the token rather than re-fetching the JWKS. During key rotation, tokens signed with the new key fail verification until the cache TTL expires. Trigger a cache refresh on SigningKeyNotFoundError.
HIGH No key overlap window during rotation — in-flight tokens fail during rotation event. The key rotation procedure replaces the signing key and updates JWKS atomically without an overlap period. Tokens signed with the old key (still within their TTL) fail verification the moment the new JWKS is published. Old keys must remain in JWKS for at least the access token TTL after they are retired from signing.
MEDIUM JWKS cache TTL unbounded — stale keys served after rotation for an unlimited period. The JWKS client is initialized with no cache TTL, or a very long one (hours). After key rotation, the server continues accepting tokens signed with the old key until the cache expires, which may be hours after the old private key was compromised. Set cache TTL to 5 minutes maximum.
Paste a GitHub URL at skillaudit.dev to get a graded report card.