JWT internals
Learn how JWTs encode claims and signatures, why the none algorithm is a critical vulnerability, and what stateless auth actually trades away compared to session tokens.
The problem
A developer integrates a JWT library to protect their API. The verify() function checks the signature when called, but nothing in the library's default configuration restricts which signing algorithm is acceptable. Their code looks like this:
// vulnerable: algorithm taken from the token's own header
const payload = jwt.verify(token, secret);
An attacker intercepts a valid token for a regular user. They decode the three Base64url-encoded parts, edit the header JSON from {"alg":"HS256","typ":"JWT"} to {"alg":"none","typ":"JWT"}, re-encode it, keep the original payload unchanged, and append an empty signature (just a trailing dot). They send the modified token to the API.
The library reads alg: none from the token header. The none algorithm means no signature is required. The library skips verification entirely and returns the payload as if valid. The attacker is now authenticated as any user they choose to put in the payload, including admin accounts.
This is not a theoretical attack. It is documented in RFC 8725 ("JWT Best Current Practices") because libraries shipped exactly this behavior. The root cause is a design mistake: letting untrusted input (the token header) dictate the verification algorithm. This is the problem that correct JWT usage solves.
What a JWT is
A JSON Web Token is a compact, self-contained credential made of three Base64url-encoded JSON objects joined by dots. The recipient verifies the token's authenticity using their own key, with no round-trip to the issuer needed.
Analogy: A government-issued passport. It contains identity claims (photo, name, nationality). The border officer checks authenticity against their knowledge of a real seal. There is no phone call to the passport office per entry. The signed document is self-verifying.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.SflKxwRJS...
β Header β Payload β Signature β
Header: {"alg": "HS256", "typ": "JWT"}
Payload: {"sub": "user_123", "exp": 1700000000,
"iss": "auth.example.com", "aud": "api.example.com"}
Signature: HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)
The payload is Base64url encoded, not encrypted. Anyone can decode and read the claims. The signature proves the claims have not been tampered with, but provides no confidentiality. Do not put secrets or sensitive PII in JWT payloads.
The D2 diagram below shows the three parts of a JWT and the verification steps a service must run on each incoming token:
spawnSync d2 ENOENT
How it works
Issue. The auth server builds the header (algorithm and type) and payload (claims including subject, expiry, issuer, audience), Base64url-encodes each, concatenates them with a dot, and computes the signature. For HS256, the signature is HMAC-SHA256 over the encoded header-dot-payload using a shared secret. For RS256, it is an RSA-SHA256 signature using a private key.
Transmit. The client stores the token and attaches it to subsequent requests as a Bearer token in the Authorization header. No session is maintained server-side. The token is the session.
Verify. The service checks the algorithm against its own allowed list (not the token header), recomputes the signature using its key, compares it byte-for-byte to the signature in the token, and validates the claims. No call to the auth server is needed.
// Issue
function issue(claims, secret):
header = { "alg": "HS256", "typ": "JWT" }
payload = claims + { "iat": now(), "exp": now() + 900 } // 15 min
encoded = base64url(header) + "." + base64url(payload)
sig = HMAC-SHA256(encoded, secret)
return encoded + "." + base64url(sig)
// Verify
function verify(token, secret, expectedAlg, expectedAud):
[hdr_b64, pay_b64, sig_b64] = token.split(".")
header = json.parse(base64url.decode(hdr_b64))
// MUST NOT take alg from the token header
if header.alg != expectedAlg:
throw "Algorithm mismatch"
expected_sig = HMAC-SHA256(hdr_b64 + "." + pay_b64, secret)
if not constant_time_equal(expected_sig, base64url.decode(sig_b64)):
throw "Invalid signature"
payload = json.parse(base64url.decode(pay_b64))
if payload.exp < now(): throw "Token expired"
if payload.aud != expectedAud: throw "Wrong audience"
return payload
The constant_time_equal comparison is required. A naive == short-circuits on the first mismatched byte, leaking timing information that an attacker can use to incrementally forge signatures.
The alg:none vulnerability
The none algorithm was included in the original JWT spec for use in trusted-channel contexts only; it was never meant for internet-facing APIs. Libraries that honor alg: none from the token header skip signature verification for any token that claims it.
// WRONG: algorithm taken from token header (default in some libraries)
const payload = jwt.verify(token, secret);
// WRONG: explicitly allowing none in the algorithm list
const payload = jwt.verify(token, secret, { algorithms: ['HS256', 'none'] });
// CORRECT: pin the algorithm explicitly, never include 'none'
const payload = jwt.verify(token, secret, { algorithms: ['HS256'] });
The attack step by step: take any valid token, decode the three dot-separated parts, change the header JSON to {"alg":"none","typ":"JWT"}, re-encode it, keep the original payload, drop the signature after the final dot, send <new-header>.<original-payload>. to the API. A vulnerable library returns the payload as verified.
One line prevents this entire attack class
Always pass { algorithms: ['HS256'] } (or your specific algorithm) to verify(). Passing the expected algorithm means any token with a different algorithm claim, including none, is rejected before any signature logic runs.
RS256 vs HS256 key confusion
HS256 uses a symmetric secret: the same key signs and verifies. Any service that can verify a token can also forge one. This is acceptable only when all verifying services are equally trusted.
RS256 uses an asymmetric key pair: the auth server signs with the private key, and any service verifies with the public key. The public key can be published openly via a JWKS endpoint. Services verify without being able to forge tokens.
The key confusion attack exploits libraries that accept both HS256 and RS256 without enforcing which one to expect. An attacker who has the RS256 public key (which is meant to be public) creates a token with alg: HS256 and signs it using the public key bytes as the HMAC secret. A library that does not enforce the expected algorithm will verify it successfully, because the library's HS256 path uses the configured key as the HMAC secret and the configured key is the RS256 public key.
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with NotesFromSDE Premium.