MCP server JWT algorithm confusion security

MCP server JWT algorithm confusion — RS256→HS256, alg:none bypass, and weak HMAC secrets

JWT algorithm confusion allows an attacker to forge tokens accepted as valid by an MCP server without ever accessing the private signing key. Three distinct patterns account for the vulnerability: the RS256→HS256 public-key-as-HMAC-secret attack, the alg:none unsigned token bypass, and offline brute-forcing of weak HMAC secrets. A SkillAudit scan of community MCP servers found 14% vulnerable to at least one pattern. The fix in each case is a one-to-three line change — but the vulnerability is invisible to normal testing because valid tokens still work correctly.

Pattern 1: RS256 → HS256 algorithm confusion

RSA-based JWT signing (RS256) uses a private key to sign and the corresponding public key to verify. HMAC-based signing (HS256) uses a shared secret for both operations. If a JWT library reads the algorithm from the incoming token header and a server is configured for RS256, an attacker can submit a token claiming alg: "HS256" and sign it with the server's public key bytes as the HMAC secret. The server calls verify with its public key material; since the library now treats it as an HMAC verification, it succeeds.

// WRONG: algorithm resolved from token header (default behavior in some libraries)
import jwt from 'jsonwebtoken';
const publicKey = fs.readFileSync('./keys/public.pem');

// No algorithms option → library uses the alg from the token header
const payload = jwt.verify(token, publicKey); // vulnerable to alg confusion

// RIGHT: algorithm pinned in the verify call
const payload = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],  // reject anything else, including HS256 and none
});

Pattern 2: alg:none bypass

RFC 7518 defines none as a valid algorithm meaning "unsecured JWS" — no signature at all. Some JWT libraries historically accepted none-signed tokens unless explicitly blocked. An attacker crafts a token with {"alg":"none"} in the header, any claims in the payload, and an empty signature. If the library doesn't reject it, the attacker authenticates as any user without a key.

// WRONG: jwt.decode() — never verifies signatures
const payload = jwt.decode(token); // alg:none accepted, signature ignored entirely

// WRONG: missing algorithms option on older jsonwebtoken versions
const payload = jwt.verify(token, secret); // may accept alg:none on jsonwebtoken < 9.0

// RIGHT: explicit algorithm allowlist blocks none, HS256 confusion, and anything else
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });

Pattern 3: weak HMAC secrets

HS256 requires a shared secret known to both the signing service and every verifying MCP server. A short or low-entropy secret is vulnerable to offline dictionary attacks: the attacker observes any valid signed token, then tries candidate secrets locally at GPU speed until the signature matches. Secrets shorter than 32 bytes with low entropy are typically cracked in seconds to minutes.

// WRONG: short/guessable secrets
const JWT_SECRET = 'secret';              // 6 bytes
const JWT_SECRET = 'my-app-jwt-secret';   // 19 bytes, predictable
const JWT_SECRET = process.env.JWT_SECRET ?? 'dev-fallback'; // default = exploitable

// RIGHT: enforce minimum entropy at startup, no fallback
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET || Buffer.from(JWT_SECRET, 'base64').length < 32) {
  throw new Error('JWT_SECRET must be a base64-encoded 32+ byte random value');
}
// Generate: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

Pattern 4: JWK URL from token header

Some OIDC libraries support the jku (JWK Set URL) header claim, fetching the verification key from a URL embedded in the token. If the server fetches and trusts a JWKS from a URL it didn't configure, an attacker can host their own JWKS at an arbitrary URL and sign tokens with their private key.

// WRONG: fetching JWKS from token-provided URL
const header = jwt.decode(token, { complete: true }).header;
const jwks = await fetch(header.jku).then(r => r.json()); // attacker-controlled URL

// RIGHT: JWKS URL pinned in server configuration
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL(process.env.JWKS_URI));
const { payload } = await jwtVerify(token, JWKS, { algorithms: ['RS256'] });

For a complete audit of your MCP server's JWT implementation — covering all four confusion patterns, issuer and audience validation, token expiry enforcement, and rotation strategy — run a SkillAudit scan. See the detailed post: JWT algorithm confusion attacks on MCP servers. Related: OpenID Connect security, OAuth 2.0 security.