
Breaking Authentication: A Practical Guide to JWT Algorithm Confusion Attacks
How a client-controlled header field can turn a signed JWT into a forgeable one, and how to find and fix it before it becomes a full authentication bypass.
Security research and platform engineering at HackerSavanna.
JSON Web Tokens are everywhere. Single-page apps use them for session state, mobile apps use them for API auth, and microservices pass them around as a stand-in for "this request already proved who it is." That ubiquity is exactly why algorithm confusion bugs keep showing up in bug bounty programs years after they were first documented: the format is simple enough that everyone rolls their own verification logic at least once, and it only takes one wrong assumption to turn a signed token into an unsigned one.
The core idea
A JWT has three parts: a header, a payload, and a signature. The header declares which algorithm was used to sign the token, for example HS256 (HMAC with a shared secret) or RS256 (RSA with a public/private key pair). Here's the problem: the header is attacker-controlled. It's just base64-encoded JSON sitting in the token the client sends back to the server. Nothing stops a client from changing "alg": "RS256" to "alg": "HS256" before sending it, and if the server's verification library blindly trusts that field to decide how to verify the signature, you have a vulnerability.
Why this actually works
Most RS256 deployments publish their public key somewhere, often at a JWKS endpoint like /.well-known/jwks.json, or it's simply embedded in a mobile app or a frontend bundle. That public key is, by definition, public. An attacker who can read it now has everything needed to forge a token, provided the server can be tricked into verifying with HMAC instead of RSA.
Here's why that trick works: HMAC verification takes a single secret and computes HMAC(secret, data). If a server's code looks like this:
1const jwt = require('jsonwebtoken');2 3function verifyToken(token) {4 const decoded = jwt.decode(token, { complete: true });5 const key = decoded.header.alg === 'RS256' ? RSA_PUBLIC_KEY : HMAC_SECRET;6 return jwt.verify(token, key); // alg not pinned - library trusts the header7}An attacker forges a token with alg: HS256, then computes HMAC(RSA_PUBLIC_KEY_PEM_STRING, header + "." + payload). If the server passes the RSA public key straight into an HMAC verification path because it trusts the attacker-supplied alg field, the signature checks out. The server just verified a "signed" token using its own public key as an HMAC secret, a secret the attacker already had.
Finding it in the wild
You don't need source access to test this. The workflow during triage usually looks like:
- Grab a legitimate RS256 token from a login flow.
- Locate the public key. Check
/.well-known/jwks.json,.well-known/openid-configuration, response headers, or decompiled mobile app assets. - Convert the JWK to PEM format if needed (
node-joseorpem-jwkhandle this in a couple of lines). - Craft a forged token:
1import jwt2 3public_key_pem = open("server_public_key.pem", "rb").read()4 5forged_payload = {6 "sub": "admin-user-id",7 "role": "admin",8 "iat": 17000000009}10 11forged_token = jwt.encode(12 forged_payload,13 public_key_pem,14 algorithm="HS256"15)16 17print(forged_token)- Replace your session token with the forged one and hit an authenticated endpoint. If it works, you have a full authentication bypass, and depending on what's in the payload, potentially a path to any account on the platform.
A close cousin of this bug is the alg: none variant, where some libraries will accept a token with no signature at all if the header says so. Both bugs come from the same root cause: trusting an attacker-controlled field to decide how trust gets established.
What makes this a good finding
Severity here almost always lands Critical or High, because a successful forgery is a direct, unauthenticated path to acting as any user, including admins if their user IDs are guessable or leaked elsewhere in the app (support tickets, changelogs, git history). When you write the report, include the exact forged token, the endpoint it was used against, and a screenshot or response body proving the elevated action succeeded. Triagers move much faster on JWT bugs when they can copy your PoC token and reproduce in under a minute.
The fix, for the other side of the table
If you're building the verification logic instead of breaking it:
- Pin the expected algorithm explicitly. Every mainstream JWT library supports this:
1jwt.verify(token, RSA_PUBLIC_KEY, { algorithms: ['RS256'] });- Never derive the verification key or algorithm from the token itself.
- Use separate, dedicated key material for HMAC and RSA flows so a leaked public key can't double as an HMAC secret even if algorithm pinning is somehow bypassed elsewhere in the code.
- Rotate keys on a schedule, and treat any exposed private key or JWKS misconfiguration as an incident, not a footnote.
Algorithm confusion is a textbook example of why "the format is simple" is not the same thing as "the implementation is safe." The bug isn't in JWT itself, it's in the assumption that a client-supplied field can be trusted to describe how the server should verify that same client's claims.
Related Posts

Prompt Injection in the Wild: Breaking LLM-Powered Applications
Direct and indirect prompt injection explained through realistic, tool-calling attack chains, plus a concrete methodology for testing AI features in scope.

IAM Privilege Escalation Paths in AWS: A Bug Hunter's Field Guide
Individually harmless-looking IAM permissions that chain together into full account compromise, and the exact commands used to find and prove each path.