The alg=none JWT vulnerability, with code that exploits it and a 5-line fix
Why the alg=none JWT bug class refuses to die, with working exploit code, the right way to refuse it, and the one HTTP call that catches every variant.
JWTs are signed JSON. The signing algorithm is announced in the token’s own
header. If your verifier honors that announcement without a separate
allowlist, an attacker writes "alg":"none" and your application accepts a
forged token. The bug is eleven years old and is still in production. Here is
the working exploit, the four-character mitigation, and the one HTTP call
that catches every shape of the issue.
TL;DR
Pin algorithms with an allowlist. Never let the token’s own alg header
choose which algorithm verification uses. If you only ever issue HS256, your
verifier must reject everything else — including none.
The mechanics
A JWT is header.payload.signature. The header includes an alg field
declaring the signing algorithm. The signature is a MAC (HS*) or signature
(RS*, ES*, EdDSA) over base64url(header) + "." + base64url(payload).
RFC 7515 §6.1
defines none as a valid alg value: an “unsecured JWS” where the signature
segment is empty. It exists for transport-of-already-signed-content cases.
It is never appropriate for an authentication context.
The vulnerability is what most JWT libraries did in 2014–2015: they read
alg from the token’s header, dispatched to the verifier for that
algorithm, and the none verifier returned true for an empty signature.
CVE-2015-9235 is the
canonical disclosure; nine other CVEs followed.
The vulnerable code
Here is a minimal Node verifier that ships the bug:
import jwt from 'some-old-jwt-library';
function verify(token, secret) {
// No `algorithms` option ⇒ the library trusts the token's alg header.
return jwt.verify(token, secret);
}
The exploit:
import { Buffer } from 'node:buffer';
const b64 = (obj) =>
Buffer.from(JSON.stringify(obj))
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const header = b64({ alg: 'none', typ: 'JWT' });
const payload = b64({ sub: 'admin', role: 'superuser' });
const forged = `${header}.${payload}.`;
verify(forged, 'whatever'); // accepts the token, returns the payload
The signature segment is empty. The server accepts a forged “admin” claim.
The right way (manual fix)
Every modern JWT library accepts an algorithms parameter that names the
allowlist. Pin it.
import jwt from 'jsonwebtoken';
function verify(token, secret) {
return jwt.verify(token, secret, { algorithms: ['HS256'] });
}
Three rules to make this safe in real code, not just in this snippet:
- The allowlist is configuration, not literal. Read from a policy file or env so you cannot accidentally widen it in a hotfix.
noneis never on the list. Even if your library considers it valid, omit it explicitly.- Asymmetric and symmetric algorithms must not share an allowlist. If you accept both HS256 and RS256, an attacker can sign with HMAC using your public RSA key as the secret. This is the key confusion class (CVE-2016-10555). Pick one family per verifier.
Edge cases the four-line fix doesn’t cover
- Tokens with no signature segment. Valid base64url for empty bytes is
the empty string, so
header.payload.is technically a 3-segment JWT. Some libraries reject empty-string signatures only fornoneand accept them for HS256 with an empty MAC. Test for it. - Capitalised
None,NONE,NoNe. RFC 7515 says comparison is case-sensitive on the literal"none", but several historical libraries did case-insensitive matching. If you switch libraries, regression-test the exact string. - The
critheader parameter. A token can claim that custom critical headers must be understood. A library that ignorescritwhile another honours it allows policy-bypass on shared infrastructure. algcoming from JWS-protected vs unprotected headers. Most JWTs use the compact serialization (single header), but the JSON serialisation allows separate protected and unprotected headers — a confusion vector during verifier swaps.
The JWTShield way
Two endpoints, both fail-closed by default.
POST /v1/inspect/token returns suspicious_warnings: ["ALG_NONE"]
whenever the header’s alg field is the literal string "none". Useful
for fixture inspection and pre-auth log lines.
POST /v1/validate/jwt rejects the token unconditionally when alg=none
appears, regardless of the policy’s allowed_algs list. There is no
configuration knob to permit none for a verification context. The
ALGORITHM_INVALID finding carries the
offending header and the configured allowlist as evidence.
curl -sSf -X POST "$JWTSHIELD_URL/v1/validate/jwt" \
-H "Content-Type: application/json" \
-d '{
"token": "eyJhbGciOiJub25lIn0.eyJzdWIiOiJhZG1pbiJ9.",
"policy": {
"secret": "your-256-bit-secret",
"issuer": "https://issuer.example.com",
"audiences": ["api://backend"],
"allowed_algs": ["HS256"]
}
}'
The response carries valid: false with a single ALGORITHM_INVALID
finding and the suite continues to refuse the token even if you make
allowed_algs more permissive.
See also
- Errors —
ALG_NONE - Errors —
ALGORITHM_INVALID - API — Inspect token
- API — Validate JWT (look at the
allowed_algspolicy field) - RFC 7515 §6.1 — Unsecured JWS