</>CodeWithKarani

JWT alg:none and RS256/HS256 Confusion: Pin Your Algorithms

Karani GeoffreyKarani Geoffrey8 min read

A security consultant sent a client of mine a two-line proof of concept. They took a valid JWT from a normal user session, changed the header to {"alg":"none"}, changed "role":"user" to "role":"admin", deleted the signature, and left the trailing dot. The API returned the full admin dashboard. Nothing in the logs looked unusual, because from the application's point of view nothing unusual happened: a token arrived, the library said it was fine, and the request was served.

The bug was not in the crypto. The signature verification code was correct RSA. The bug was that the code asked the token which algorithm to use to check the token. That is the whole vulnerability class, and it has two famous shapes: alg: none, and swapping RS256 for HS256 so the server verifies with the public key as if it were a shared secret.

If you take one thing from this article: the algorithm is part of your configuration, not part of the message. A JWT verifier that reads alg out of an untrusted token and uses it to pick a verification path is not verifying anything.

pass an explicit algorithm allowlist to every verify call, hardcoded next to where you configure the key.

  • PyJWT: jwt.decode(token, key, algorithms=["RS256"]) - the algorithms argument is mandatory in PyJWT 2.x, do not compute it from the token.
  • Node jsonwebtoken: jwt.verify(token, key, { algorithms: ["RS256"] }).
  • Go golang-jwt: jwt.Parse(tok, keyFunc, jwt.WithValidMethods([]string{"RS256"})).
  • Never register the same key material for both an asymmetric and a symmetric algorithm, and never fall back to a "default" algorithm when the header is missing.
  • Add a test that forges an alg: none token and an HS256 token signed with your public key, and asserts both are rejected. If you have no such test, assume you are vulnerable.

The exact thing an attacker sends: "alg": "none"

The forged header, before base64url encoding:

{
  "alg": "none",
  "typ": "JWT"
}

The resulting token looks like a normal JWT with an empty third segment. Note the trailing dot, which is required:

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxIiwicm9sZSI6ImFkbWluIn0.

The second shape does not announce itself at all. The header is a perfectly ordinary:

{
  "alg": "HS256",
  "typ": "JWT"
}

and the signature is a real, valid HMAC-SHA256 - computed with your RSA public key as the HMAC secret. The public key is public. That is the point of it. If your server hands the raw public key to an HMAC verifier, anyone who can fetch your JWKS endpoint can mint tokens.

Why a correct crypto library still accepts a forged token

JWS, the signature format underneath JWT, puts the algorithm identifier in the protected header. That was a deliberate design choice so that a single verifier could handle multiple algorithms, and RFC 7515 even registers none for "unsecured" JWS objects that are protected some other way. Reasonable in the abstract. Catastrophic as a default.

Early library APIs mirrored the spec directly. verify(token, key) would parse the header, read alg, look up the matching algorithm implementation, and run it. The attacker controls the header. So the attacker controls which code path runs.

Unsafe: the token picks the algorithm Untrusted token header.alg = "none" Library reads alg selects verifier by header No signature check runs claims trusted as-is Safe: configuration picks the algorithm Untrusted token header.alg = anything algorithms=["RS256"] from your config, not the token Header must match, or reject then RSA verify with public key The only difference is where the string "RS256" comes from. One version reads it from the attacker. The other reads it from you.
Both paths run the same signature code. Only one of them decides in advance what "verified" is allowed to mean.

The RS256-to-HS256 case is subtler because it is not a missing check, it is a type confusion in the key material. Your verifier holds one blob of bytes it calls "the key". If the header says RS256, that blob is interpreted as an RSA public key. If the header says HS256, the same blob is interpreted as an HMAC secret. An attacker who has your public key (and they do, that is what "public" means) can compute a valid HMAC over any payload they like.

Most maintained libraries have blocked this specific pairing for years, and the well-known Node jsonwebtoken case was fixed as CVE-2015-9235 in version 4.2.2. But treat those fixes as bug fixes, not as an architectural guarantee. Pinning the algorithm is the guarantee. The library fix is a seatbelt on top of it.

The fix, step by step

Step 1: find every place a token is verified

Not "the auth middleware". Every place. Gateways, background workers, webhook receivers, an internal admin service that someone wrote in a hurry, and any test helper that has quietly become production code.

grep -rnE "jwt\.(decode|verify|Parse)|jwtVerify|decodeJwt" \
  --include='*.py' --include='*.js' --include='*.ts' --include='*.go' .

Expect to be surprised. In the codebase I opened this article with, there were four verify sites and only two of them were in the file called auth.py.

Step 2: pin the algorithm at each one

PyJWT 2.x already refuses to run without an allowlist, which is why upgrading off PyJWT 1.x is the single highest-value change in a Python codebase:

import jwt
from jwt.exceptions import InvalidAlgorithmError, InvalidTokenError

ALLOWED_ALGS = ["RS256"]  # configuration, next to the key

def verify(token: str) -> dict:
    return jwt.decode(
        token,
        PUBLIC_KEY,
        algorithms=ALLOWED_ALGS,
        audience="api://orders",
        issuer="https://auth.example.com",
    )

A token whose header carries a different alg now raises InvalidAlgorithmError with the message The specified alg value is not allowed, before any signature work happens.

Node, using jsonwebtoken:

const jwt = require("jsonwebtoken");

function verify(token) {
  return jwt.verify(token, PUBLIC_KEY, {
    algorithms: ["RS256"],
    audience: "api://orders",
    issuer: "https://auth.example.com",
  });
}

Go, using golang-jwt/jwt, where the option is a parser option rather than a claims option:

token, err := jwt.Parse(raw, keyFunc, jwt.WithValidMethods([]string{"RS256"}))

The comparison that matters across languages:

LibraryPinning callWhat happens without it
PyJWT 2.xalgorithms=["RS256"]Refuses to decode; the argument is required
PyJWT 1.xalgorithms=["RS256"]Falls back to the header value. Upgrade.
jsonwebtoken (Node){ algorithms: ["RS256"] }Infers from the key type since 4.2.2, but leaves you one key-loading refactor away from trouble
jose (Node/browser){ algorithms: ["RS256"] }Rejects none outright, but still accepts any other registered alg
golang-jwtjwt.WithValidMethods([...])Your keyFunc must type-assert the method yourself, and most examples do not

Step 3: stop one key from serving two algorithm families

Pinning fixes the common case. The structural fix is to make the confusion impossible to express. Load your RSA public key into a key object that your library will refuse to hand to an HMAC function, rather than passing a PEM string around. In PyJWT that means using a cryptography public key object; in Node jose it means importSPKI rather than a raw string; in Go it means a *rsa.PublicKey.

If your service verifies tokens from more than one issuer, give each issuer its own verifier with its own pinned algorithm list. A single verifier with algorithms: ["RS256", "HS256"] is the vulnerability wearing a hat.

Step 4: check the header fields that also select keys

kid, jku, x5u and jwk are attacker-controlled too. kid is the common one: if you use it as a filename or a SQL lookup, you have a path traversal or an injection point. Treat it as an opaque lookup key against a fixed set you already trust. Never fetch a key from a URL that came out of the token; if you use JWKS, the JWKS URL is configuration and the only thing the token gets to choose is which kid within that fetched set.

Verification: forge a token and prove it fails

This is the part that turns a fix into a permanent fix. Write the attack as a test.

import base64, hmac, hashlib, json
import pytest, jwt
from jwt.exceptions import InvalidTokenError


def b64(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def test_alg_none_is_rejected():
    header = b64(json.dumps({"alg": "none", "typ": "JWT"}).encode())
    payload = b64(json.dumps({"sub": "1", "role": "admin"}).encode())
    forged = f"{header}.{payload}."
    with pytest.raises(InvalidTokenError):
        verify(forged)


def test_hs256_signed_with_public_key_is_rejected():
    header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
    payload = b64(json.dumps({"sub": "1", "role": "admin"}).encode())
    signing_input = f"{header}.{payload}".encode()
    sig = hmac.new(PUBLIC_KEY_PEM_BYTES, signing_input, hashlib.sha256).digest()
    forged = f"{header}.{payload}.{b64(sig)}"
    with pytest.raises(InvalidTokenError):
        verify(forged)

Both tests should pass immediately after step 2. Now run them against the commit before your fix. If they still pass, either you were already safe or your test is not reaching the real verifier - check that verify() in the test is the same function the request path uses, not a copy.

For a running service, curl it:

curl -i -H "Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxIiwicm9sZSI6ImFkbWluIn0." \
  https://api.example.com/v1/admin/users

Expected: HTTP/1.1 401 Unauthorized. Anything in the 200 range means stop reading and go fix it.

What people get wrong

"We use RS256, so algorithm confusion does not apply to us." Backwards. RS256 is the precondition for the public-key-as-HMAC-secret attack. HS256-only services cannot be hit by that variant at all, because there is no public key to abuse.

"We block alg: none, so we are done." A string blocklist on none misses HS256 confusion entirely, and misses case variants like None and nOnE that some parsers historically normalised. Allowlists, not blocklists. Always.

Using decode when you meant verify. Every JWT library has an unverified-decode function for reading a token's claims without checking the signature: jwt.decode(token, options={"verify_signature": False}) in PyJWT, jwt.decode() in Node jsonwebtoken, decodeJwt() in jose. These are for logging and debugging. I have seen all three used to extract a tenant ID for a database query, which turns a decorative function into an authorisation bypass. Grep for them specifically.

"The gateway validates it." Then the app must too, or it must be unreachable except through the gateway at the network level. A service that trusts a header because something upstream is supposed to have checked it is one misrouted internal call away from a bad day. This is the same reasoning behind not storing JWTs in localStorage: assume the layer above you will fail.

Validating claims but not the signature order. Checking exp, aud and iss is necessary and does nothing here. A forged token can carry a perfectly valid expiry.

When it is still broken

  • Two verifiers, one pinned. Search again for the unverified-decode functions listed above, and for any hand-rolled base64 splitting on a token string. A .split(".") near an auth check is a red flag.
  • A framework doing it for you. Some auth middlewares accept an algorithm option and silently default to permissive when it is absent. Read the middleware source for the default, do not trust the README.
  • Symmetric secret that is not secret. If you use HS256, the secret must have real entropy and must not be in the repository. A 12-character shared secret is brute-forceable offline from a single captured token. Move it into a proper secrets workflow, such as the SOPS and age setup I use for small teams.
  • Refresh tokens on a different code path. Refresh endpoints are frequently written months after the access-token path and by a different person. Check that rotation logic verifies with the same pinned settings.
  • Old tokens still valid. After fixing verification, rotate the signing key. Any token an attacker forged before the fix stays valid until the key changes or the token expires.

RFC 8725, the JWT Best Current Practices document, exists precisely because this class of bug kept recurring in real deployments. It is short and worth twenty minutes: RFC 8725. Its first recommendation is the one in this article, and it is the one people skip.

Frequently asked questions

Is alg:none still exploitable in modern JWT libraries?
In maintained versions of the major libraries, no - PyJWT 2.x requires an explicit algorithms list, and Node's jsonwebtoken has rejected the unsigned case since version 4.2.2 (CVE-2015-9235). It is still exploitable in unmaintained libraries, in hand-rolled verifiers, and in any code that calls an unverified decode function and then trusts the claims. Pin the algorithm explicitly anyway, because that is a property of your code rather than of a dependency version you may downgrade or replace.
How does the RS256 to HS256 attack actually work?
The server holds an RSA public key to verify RS256 tokens. The attacker changes the token header to HS256 and signs their forged payload using that same public key as the HMAC shared secret. If the server picks its verification algorithm from the token header, it hands the public key bytes to an HMAC function, recomputes the same MAC, and the signature matches. The public key is published in your JWKS endpoint, so the attacker already has everything they need.
Do I still need to pin algorithms if I only use HS256?
Yes. Pinning to ["HS256"] stops an attacker downgrading to alg:none, and stops a future refactor that introduces an RSA key from silently opening the confusion path. It costs one argument. The wider risk with HS256 is secret strength: a short shared secret can be brute-forced offline from a single captured token, so use at least 32 bytes of real randomness and keep it out of the repository.
How do I test whether my API is vulnerable to alg:none?
Take a valid token, base64url-decode the header, set alg to none, edit a claim in the payload such as a role or user ID, re-encode both, and send header.payload. with an empty third segment and the trailing dot intact. Send that as your Authorization bearer token to a protected endpoint. A 401 means you are fine; any successful response means the endpoint is verifying nothing and needs an explicit algorithms allowlist.
#JWT#Authentication#PyJWT#Node.js#Application Security
Keep reading

Related articles