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.
TL;DR
A JWT is a compact, signed representation of claims that a recipient can verify without calling the issuer for every request. Signing does not encrypt the payload, and safe validation requires checking the algorithm, issuer, audience, time claims, key identity, and the token's lifecycle policy.
30-Second Explanation
The issuer encodes a header and payload, signs header.payload, and returns the three-part token. A service verifies the signature with a shared secret or public key, then applies claim checks before authorizing the request. Stateless verification improves availability and latency, while revocation, key rotation, token size, and compromised credentials make server-side sessions or introspection preferable in some systems.
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:
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.
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with SDEpedia Premium.