Authentication Security

JWT algorithm confusion attacks on MCP servers: RS256→HS256, alg:none, and weak HMAC secrets

2026-06-12 · 11 min read

CVSSv3 9.8 — Critical — authentication bypass

JSON Web Tokens are the standard authentication mechanism for MCP servers that expose HTTP endpoints. A well-configured JWT stack — RS256 asymmetric signing, explicit algorithm allowlisting, minimum key length enforcement — provides robust authentication. But the JWT specification leaves algorithm selection to the application, and three related vulnerability patterns allow an attacker to forge a token that the server accepts as valid without ever knowing the private key.

SkillAudit's static analysis of 400+ community MCP servers found that 14% were vulnerable to at least one algorithm confusion pattern: 9% accepted tokens with attacker-chosen algorithms (the classic RS256→HS256 confusion), 4% had an alg:none-accepting code path, and 7% used HMAC secrets under 32 bytes — short enough to crack via offline dictionary attack. Some servers had multiple issues.

This post walks through all three patterns with exact Node.js code showing how each attack works, why the naive implementation fails, and what the correct implementation looks like. The fix in each case is a one-to-five line change that SkillAudit's remediation report highlights directly.

Pattern 1: RS256 → HS256 confusion

Attacker signs a token with HS256 using the server's public key as the HMAC secret. If the server reads alg from the token header, it verifies the token using its own public key — and succeeds.

Pattern 2: alg:none bypass

Attacker sets "alg":"none" in the token header and strips the signature. If the server doesn't explicitly block the none algorithm, some JWT libraries skip verification entirely.

Pattern 3: weak HMAC secret

Server uses a short or guessable HMAC secret (e.g. secret, password, an 8-character hex string). Attacker recovers the secret via offline dictionary attack and signs arbitrary claims.

Pattern 1: RS256 → HS256 algorithm confusion

This is the highest-severity of the three patterns and the most subtle. It requires only the server's public key — which is public by definition, often exposed at /.well-known/jwks.json — to forge tokens for any user.

The attack works because of an asymmetry in how asymmetric and symmetric algorithms use keys. RS256 uses a private key to sign and the corresponding public key to verify. HS256 uses the same shared key to sign and verify. If an MCP server uses RS256 but reads the algorithm from the incoming token header instead of hardcoding the expected algorithm, an attacker can submit a token with "alg":"HS256" signed using the server's public key bytes as the HMAC secret. The server looks up its key material, takes the public key bytes, and runs HS256 verification — which succeeds because the attacker signed with those same bytes.

Why it matters for MCP servers specifically: MCP servers often expose their JWKS endpoint publicly so Claude Code and other clients can discover signing keys automatically. This means the attacker needs only the public URL of the server to retrieve the public key material needed to forge tokens.

Wrong

// WRONG: algorithm taken from the incoming token header
// An attacker sets alg:HS256 in their token and signs it with your public key bytes
import jwt from 'jsonwebtoken';
import fs from 'node:fs';

const publicKey = fs.readFileSync('./keys/public.pem');

function verifyToken(token) {
  // jsonwebtoken reads alg from the header if algorithms is not specified
  // With no algorithms allowlist, the library uses whatever alg the token claims
  const payload = jwt.verify(token, publicKey);
  return payload;
}

// Attack:
// 1. Attacker fetches /jwks.json, converts the RSA public key to PEM
// 2. Attacker crafts: { alg: "HS256", typ: "JWT" }.{ sub: "admin", role: "admin" }
// 3. Attacker signs with HMAC-SHA256 keyed on the public key bytes
// 4. jwt.verify() receives alg:HS256, interprets publicKey as an HMAC secret, succeeds

Right

// RIGHT: algorithms explicitly allowlisted; only the expected algorithm is accepted
import jwt from 'jsonwebtoken';
import fs from 'node:fs';

const publicKey = fs.readFileSync('./keys/public.pem');

function verifyToken(token) {
  // RIGHT: pass an explicit algorithms array — library rejects any other alg value
  // including HS256, HS384, HS512, RS384, RS512, PS*, ES*, 'none'
  const payload = jwt.verify(token, publicKey, {
    algorithms: ['RS256'],          // ONLY accept RS256
    issuer: 'https://auth.example.com',
    audience: 'mcp-server',
  });
  return payload;
}

// With algorithms: ['RS256'], a token with alg:HS256 throws:
// JsonWebTokenError: invalid algorithm
// — even if the signature happens to verify against the public key bytes

The same principle applies to jose, @node-rs/jsonwebtoken, and every other JWT library: always pass an explicit algorithm allowlist, never let the library default to accepting whatever the token header claims. If you use JWKS-based key lookup, pin the expected algorithm per key ID so that a compromised or rotated key can't downgrade the algorithm:

// RIGHT: JWKS-based verification with algorithm pinning per key
import { createRemoteJWKSet, jwtVerify } from 'jose';

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

async function verifyToken(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    algorithms: ['RS256'],   // still required even with JWKS
    issuer: 'https://auth.example.com',
    audience: 'mcp-server',
  });
  return payload;
}

// jose's jwtVerify will reject the token if:
// - the alg header doesn't match algorithms
// - the key in the JWKS for that kid doesn't match the alg
// Two independent checks; both must pass.

Pattern 2: alg:none bypass

The none algorithm is defined in RFC 7518 as "unsecured JWS" — a token with no signature at all. This was intended for situations where the token integrity is guaranteed by the transport layer and no signature is needed. In practice, no MCP server should ever accept unsigned tokens, but several popular JWT libraries historically accepted none unless explicitly blocked.

The attack is trivially simple: craft a JSON payload with elevated privileges, base64url-encode the header and claims, concatenate with dots, and append an empty signature. Submit this as a Bearer token. If the server calls jwt.decode() instead of jwt.verify(), or if the library defaults to accepting none, the attacker is authenticated as any user they choose.

Wrong

// WRONG: jwt.decode() does not verify signatures — it only parses the payload
// This pattern appears in MCP servers that "just need to read the user ID" from the token
import jwt from 'jsonwebtoken';

function getUserFromToken(token) {
  // jwt.decode() NEVER verifies the signature
  // An attacker can submit { alg: "none" }.{ sub: "admin" }. (empty sig) and
  // this returns { sub: "admin" } without any verification
  const payload = jwt.decode(token);
  return payload?.sub;
}

// Also wrong: calling verify() but not blocking alg:none explicitly
// Older jsonwebtoken versions (< 9.0.0) accepted none by default
// jsonwebtoken >= 9.0.0 blocks none by default — but don't rely on version behavior
function verifyTokenUnsafe(token) {
  return jwt.verify(token, secret); // missing algorithms array — risky
}

Right

// RIGHT: always verify, never decode; explicitly block the none algorithm
import jwt from 'jsonwebtoken';

const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY;

function verifyToken(token) {
  // RIGHT: jwt.verify() with explicit algorithms that do NOT include 'none'
  // If the token has alg:none, verify throws: JsonWebTokenError: invalid algorithm
  const payload = jwt.verify(token, PUBLIC_KEY, {
    algorithms: ['RS256'],
    complete: false,  // return just the payload, not the full decoded object
  });
  return payload;
}

// If you genuinely need to inspect the header before verifying (e.g. to select
// the right key by kid), decode first, then verify against a pinned algorithm:
function verifyWithKidLookup(token) {
  const header = jwt.decode(token, { complete: true })?.header;
  if (!header || header.alg !== 'RS256') {
    throw new Error('invalid algorithm');
  }
  const key = getPublicKeyByKid(header.kid);
  return jwt.verify(token, key, { algorithms: ['RS256'] });
}

Note on library versions: jsonwebtoken 9.0.0 (released 2022-12) changed the default to reject none. However, SkillAudit sees many community MCP servers pinned at older versions — "jsonwebtoken": "^8.5.1" is common — which means they still require the explicit block. Always specify the algorithms array regardless of library version.

Pattern 3: weak HMAC secrets

HMAC-based JWT algorithms (HS256, HS384, HS512) use a shared secret for both signing and verification. The security of the entire authentication system depends on that secret being unguessable. An attacker who can observe a valid token can run an offline dictionary attack against the signature without making any requests to the server: they try candidate secrets locally, re-sign the known payload, and compare against the observed signature. With modern GPUs, this is fast enough to crack any secret shorter than ~128 bits of entropy in seconds to minutes.

SkillAudit finds weak HMAC secrets via three static signals: direct string literals assigned to JWT secret variables, secrets loaded from environment variables that fail the entropy threshold check (if the default value is short), and secrets derived from predictable patterns like the server name, version string, or a timestamp.

Wrong

// WRONG: hardcoded short secrets
const JWT_SECRET = 'secret';                    // 6 bytes — cracked in milliseconds
const JWT_SECRET = 'my-mcp-server-secret';      // 20 bytes — cracked in seconds
const JWT_SECRET = process.env.JWT_SECRET ?? 'fallback-secret'; // default = exploitable

// WRONG: predictable secret derived from server metadata
const JWT_SECRET = `${packageJson.name}-${packageJson.version}`; // 25 bytes, known pattern

// WRONG: secret from an entropy-poor source
const JWT_SECRET = Date.now().toString(16);     // 8-byte hex of milliseconds since epoch
                                                // only ~33 bits of effective entropy

Right

// RIGHT: generate a cryptographically random secret at server startup
import { randomBytes } from 'node:crypto';

// Generate and log the secret once; store in Secrets Manager, not .env
// 32 bytes = 256 bits of entropy — infeasible to brute-force offline
const JWT_SECRET = process.env.JWT_SECRET;

if (!JWT_SECRET || Buffer.from(JWT_SECRET, 'base64').length < 32) {
  // Fail fast at startup rather than running with a weak secret
  console.error('JWT_SECRET must be a base64-encoded 32+ byte random value');
  console.error('Generate one with: node -e "require(\'crypto\').randomBytes(32).toString(\'base64\')"');
  process.exit(1);
}

// Even better: switch from HS256 to RS256 and eliminate shared secrets entirely.
// With RS256, the private key stays on the auth server; MCP servers only need
// the public key to verify — there's nothing to steal from an MCP server breach.

The cleanest long-term fix is to stop using symmetric HMAC-based JWTs at all. With RS256 or ES256, the private signing key lives only on the authentication server. A compromised MCP server exposes only the public key — which was already public. The attacker cannot forge new tokens, and any tokens they obtained from the compromised server expire at their TTL.

Bonus: JWK endpoint manipulation

A fourth pattern, less common but worth noting, occurs when MCP servers fetch their verification keys dynamically from a URL specified in the token header (the jku or x5u claim). If the server fetches and trusts a JWK set from an attacker-controlled URL, the attacker can publish their own public key and sign tokens with the corresponding private key.

// WRONG: using jku from the token header to fetch the verification key
// An attacker sets jku: "https://evil.com/jwks.json" in their token header
// and hosts a JWKS with their own public key — the server fetches it and
// verifies the attacker's token as valid
import { importJWK, jwtVerify } from 'jose';

async function verifyToken(token) {
  const header = decodeHeader(token);
  // NEVER fetch the JWKS URL from the token itself
  const jwks = await fetch(header.jku).then(r => r.json());
  const key = await importJWK(jwks.keys[0]);
  return jwtVerify(token, key); // attacker controls the key!
}

// RIGHT: pin the JWKS URL in server configuration, never from the token
const TRUSTED_JWKS = createRemoteJWKSet(
  new URL(process.env.JWKS_URI ?? 'https://auth.example.com/.well-known/jwks.json')
);

async function verifyToken(token) {
  return jwtVerify(token, TRUSTED_JWKS, { algorithms: ['RS256'] });
}

How SkillAudit detects these patterns

SkillAudit's static analysis uses tree-sitter to parse JavaScript and TypeScript source trees. For JWT algorithm confusion specifically, the scanner looks for four signal categories:

Signal Pattern matched Severity
Missing algorithms option jwt.verify(token, key) with no options object, or options object missing algorithms key Critical
none in algorithms list algorithms: [... 'none' ...] in any verify call Critical
Short literal secret String literal assigned to a variable named secret, jwtSecret, JWT_SECRET, etc., with byte length < 32 Critical
Short default secret process.env.X ?? 'short_default' where the default is < 32 bytes High
jwt.decode() for auth jwt.decode() result used in authentication/authorization branch Critical
Dynamic JWK URL fetch() call where the URL is derived from decoded token claims (header.jku, payload.iss with path appended) High
Missing issuer/audience jwt.verify() without issuer and audience options Medium

An MCP server with any critical signal receives a maximum grade of C regardless of its scores on other axes. A server with multiple critical JWT signals receives an automatic F on the Authentication axis. The full remediation report includes the exact file, line number, and the replacement code pattern for each finding.

Why these patterns persist

Algorithm confusion vulnerabilities are surprisingly sticky in the MCP server ecosystem for three reasons. First, the JWT specification deliberately leaves algorithm selection to the application, so naive implementations that follow a basic tutorial often miss the constraint. Second, the vulnerability is invisible to normal testing — the server works correctly with legitimate tokens, so unit tests and manual QA never trigger it. Third, the attack requires knowledge of the server's public key, which creates a false sense of security: developers assume that "you'd need to know the public key" is a meaningful barrier, not realizing that the public key is public by definition.

The SkillAudit scan catches these patterns without running any code. The static analysis parses the source tree, identifies JWT library calls, and checks for the algorithm constraint in the options object. For most servers this is a one-line fix — adding algorithms: ['RS256'] to the verify call — but the fix has to be made before a motivated attacker finds the JWKS endpoint.

Quick wins

JWT algorithm confusion is one of the clearest examples of a vulnerability class where the correct pattern is only marginally more complex than the wrong one. The difference between an A-grade and an F-grade authentication implementation is often a single options object. For a complete review of your MCP server's authentication posture — JWT config, OAuth flows, PKCE enforcement, token rotation — run a SkillAudit scan. The report identifies every algorithm confusion risk, ranks them by severity, and gives you the exact replacement code for each finding.

For related reading on MCP server authentication security, see the posts on the full security checklist, JWT validation patterns, and OAuth 2.0 security for MCP servers.