02.LAB · Bonus Level

JWT Key Confusion

The server expects RS256-signed tokens. It accepts the algorithm from the header. Watch what happens when you sign HS256 using the RSA public key as the HMAC secret.

The basic JWT Tampering Lab showed the alg:none attack and trivial tampering. This bonus level is harder, more interesting, and still found in real-world bug bounties — the RS256-to-HS256 key-confusion attack.

The setup: the server uses RS256 (RSA signatures) and publishes its public key. The verification library checks the alg field in the token header and dispatches to either an RSA verify or an HMAC verify. If the attacker changes alg to HS256 and signs the token using the RSA public key as the HMAC secret, the verify path uses that same public key as the HMAC key — and the signature matches.

The vulnerable server
// Heliotrope SSO · token verification
const jwt = require("jsonwebtoken");
const publicKey = fs.readFileSync("./public.pem");  // shipped with the app

function verifyToken(token) {
  // BUG: no explicit algorithm list. The library reads alg from the token header.
  return jwt.verify(token, publicKey);
}
1
Start with a real token from the legitimate flow

The server issued this RS256-signed token after a normal login as alice. The signature is valid; the role is employee.

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZUBoZWxpb3Ryb3BlLmNvbSIsInVzZXJfaWQiOjEwMDEsInJvbGUiOiJlbXBsb3llZSIsImlhdCI6MTcxNzU5MjAwMCwiZXhwIjoxNzE3NTk1NjAwfQ.jE0Hk9pWqXqyAr4eFR3p_LXdFnKjJTbZb1rXt5ZJ2c7E...truncated...
Header
{
  "alg": "RS256",
  "typ": "JWT"
}
Payload
{
  "sub": "alice@heliotrope.com",
  "user_id": 1001,
  "role": "employee",
  "iat": 1717592000,
  "exp": 1717595600
}
Signature
RSA-SHA256 over
header + "." + payload,
signed with private key.

(opaque to us — we don't
have the private key)

Goal: elevate role to admin and have the server accept it.

2
Grab the public key

The public key is published — that's what public means. Common publishing locations: a .well-known/jwks.json endpoint, an OAuth discovery document, the app's GitHub repo, the docs site. Often you can also extract it from any valid token if the API responds with a JWK in the header.

-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxK8e0w7g5x9N3jJp4n7K W2eR4q1L6Z8Y3vE5bF7M9c2A4hN6pT8oQrU0yJ3wXmV1iLkH5sB7nP2cD9fA6gE ... (typical 2048-bit RSA public key, ~450 bytes base64-encoded) ... xJ4WqAmK3sRtP9bD5fG7nE6cH8oL2vQ1yU0aS3eR4q6Z5Y8X9B7N1mP3cD9wF -----END PUBLIC KEY-----

Anyone can have this. It is published for a reason. The server expects this key will be used only to verify RSA signatures — not to sign anything.

3
Build a forged token, signed HS256

Modify the payload to set role: "admin". Change the header's alg from RS256 to HS256. Compute the HMAC-SHA256 signature using the public key bytes as the HMAC secret.

4
Send it to the server

The server reads alg: HS256 from the header and dispatches to the HMAC verify path. It uses publicKey (the variable) as the key — which is now being interpreted as an HMAC secret. Your HMAC signature matches.

Why this works

JWT libraries take the algorithm from the token header by default and dispatch to the corresponding verify routine. The routine is told which key to use — but the routine doesn't check that the key matches the algorithm. For RSA, the key is expected to be an RSA public key. For HMAC, the key is expected to be a shared secret — just bytes.

An RSA public key is also just bytes. When the HMAC verify routine receives publicKey (the variable that holds the PEM-encoded RSA public key), it treats those bytes as an HMAC secret. The attacker signed their forged token with the same bytes, so the HMAC check passes.

The root cause is the library's "smart" algorithm dispatch — specifically, that the application code does not specify which algorithms it expects. The server intended to use RSA; the library obediently switched to HMAC the moment the token's header asked it to.

The fix

Two fixes, applied together:

Most JWT libraries written in the last few years default to algorithm-pinning. The legacy default of "read alg from header" is responsible for years of CVEs across virtually every language ecosystem.

The point

The key confusion attack is one of the most elegant bugs in identity systems: the cryptographic primitive itself accepts the attacker's substitution because the verifier doesn't check that the key matches the algorithm. The fix is a single keyword argument. The mistake costs entire applications.

The general lesson generalizes beyond JWT: when a protocol negotiates an algorithm, the verifying party must pin which algorithms are acceptable. Otherwise the attacker picks the algorithm that they have the key for, and the verifier obediently follows.

References

Formatted in APA 7. Alphabetized by first author's last name.

  1. Auth0. (2015). Critical vulnerabilities in JSON Web Token libraries. https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/
  2. Jones, M., Bradley, J., & Sakimura, N. (2015). JSON Web Token (JWT) (Request for Comments No. 7519). Internet Engineering Task Force. https://datatracker.ietf.org/doc/html/rfc7519
  3. OWASP Foundation. (n.d.). JSON Web Token for Java cheat sheet. https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html