v0.0.1 · system status
Open APIOpenAPI 3.1Zero token storage

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

The fix in four characters

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:

  1. The allowlist is configuration, not literal. Read from a policy file or env so you cannot accidentally widen it in a hotfix.
  2. none is never on the list. Even if your library considers it valid, omit it explicitly.
  3. 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 for none and 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 crit header parameter. A token can claim that custom critical headers must be understood. A library that ignores crit while another honours it allows policy-bypass on shared infrastructure.
  • alg coming 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