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.
// WRONG: no algorithm restriction; library reads alg from token header
const payload = jwt.verify(token, rsaPublicKey);
// CORRECT: lock to RS256 explicitly
const payload = jwt.verify(token, rsaPublicKey, { algorithms: ['RS256'] });
| Algorithm | Key type | Who can forge | Recommended for |
|---|---|---|---|
| HS256 | Shared secret | Any holder of the secret | Single service or fully trusted internal services |
| RS256 | RSA key pair (private sign, public verify) | Only the private key holder | Multi-service; public JWKS endpoint for verification |
| ES256 | ECDSA key pair | Only the private key holder | Same as RS256 with smaller signatures (64 bytes vs 256 bytes) |
Algorithm choice at a glance
For new systems, default to RS256 or ES256 (asymmetric). Any service can verify tokens by fetching the public key from a JWKS endpoint without being able to forge them. Use HS256 only when all verifying services are equally trusted internal services and sharing a secret is operationally simple. ES256 produces 64-byte signatures vs RS256's 256-byte signatures, which is meaningful at high request volume. Never include none in your allowed-algorithms list under any circumstances, even in test environments.
JWT revocation: the statelessness trap
Stateless tokens cannot be revoked before their expiry. You issued a token with exp 15 minutes from now. The user's account is compromised and the security team revokes all sessions. The old access tokens remain valid for up to 15 minutes. There is no server-side session record to delete.
Two pragmatic strategies cover the majority of real-world threat models:
Short expiry plus rotating refresh tokens. Access tokens live for 15 minutes. A separate refresh token (opaque, stored server-side) lives for days or weeks. When the security team invalidates a user's sessions, they delete the refresh token. The attacker's current access token remains valid for at most 15 minutes, then the refresh attempt fails. Blast radius bounded by token lifetime. No per-request lookup.
Redis blocklist for immediate revocation. Every JWT includes a jti (JWT ID) claim, a unique identifier. On revocation, write the jti to Redis with TTL equal to the token's remaining lifetime. Verification checks Redis before accepting the token.
// Blocklist-based revocation
function revoke(token):
payload = decode_without_verify(token)
ttl = payload.exp - now()
redis.set("blocklist:" + payload.jti, "1", TTL=ttl)
function verify_with_revocation_check(token, secret):
payload = verify(token, secret, { algorithms: ['HS256'] })
if redis.exists("blocklist:" + payload.jti):
throw "Token revoked"
return payload
The blocklist reintroduces statefulness. If Redis is unavailable, you must choose to fail-open (accept potentially revoked tokens) or fail-closed (reject all requests). Short expiry is simpler and scales better. Use the blocklist only when immediate revocation is a hard requirement, for example, for privileged admin access or financial transaction signing.
Claims to validate
A JWT arrives with a valid signature but the wrong audience. Do you accept it?
Without explicit claims validation, a careless library accepts it. This is a real vulnerability in multi-service architectures: a token issued for api.payments.example.com with a valid issuer also verifies correctly on api.notifications.example.com if neither service checks aud. Cross-service token replay moves privileges from one service to another.
| Claim | Purpose | Attack if skipped |
|---|---|---|
exp (expires at) | Bounded token lifetime | Replay of old captured tokens indefinitely |
iss (issuer) | Confirms which auth server issued it | Tokens from a test or rogue issuer accepted in production |
aud (audience) | Confirms intended recipient service | Token for service A accepted by service B (cross-service replay) |
nbf (not before) | Token not valid before a timestamp | Clock skew exploitation to reuse pre-issued tokens |
jti (JWT ID) | Unique token ID for deduplication | Replay prevention and blocklisting are impossible without it |
Most JWT libraries require opting into aud checking explicitly. In Node's jsonwebtoken, pass { audience: 'api.example.com', issuer: 'auth.example.com' } to verify(). Omitting these options means the library checks only the signature and exp.
Production usage
| System | JWT usage | Notable behavior |
|---|---|---|
| Auth0 | Short-lived access tokens (1 hour) plus rotating refresh tokens stored server-side | Refresh token rotation invalidates the previous refresh token on each use; RS256 with public JWKS endpoint |
| AWS Cognito | RS256 by default; public keys at /.well-known/jwks.json | Tokens carry Cognito-specific claims (cognito:groups, cognito:username); aud must match the App Client ID |
| API gateways (Kong, AWS API Gateway) | Signature verified at gateway before forwarding to upstream services | Claims forwarded as headers; upstream trusts the gateway and skips re-verification (but should still check aud) |
| OAuth 2.0 (RFC 9068) | Structured access token format using JWT; standardizes iss, sub, aud, exp, and scope claims | Libraries implementing RFC 9068 enforce claim validation by specification; tokens are interoperable across identity providers |
Limitations
- Cannot revoke before expiry without external state. The statelessness property that makes JWTs scale is also why a compromised token remains valid until expiry. Every revocation scheme reintroduces some form of server-side state.
- Payload is visible to anyone. Base64url is encoding, not encryption. Do not store PII, passwords, or sensitive business data in JWT claims. Use JWE (JSON Web Encryption) if the payload must be confidential.
- Algorithm confusion and alg:none are production vulnerabilities. They require exactly one defensive line of code and have been exploited in deployed systems. Missing it in a code review is a critical miss.
- JWTs are larger than opaque tokens. A JWT is 100 to 500 bytes. A session cookie or opaque reference is 16 to 32 bytes. At high request volumes, this difference appears in bandwidth and header parsing overhead.
- Missing
audcheck enables cross-service replay. Most libraries make audience validation opt-in. Forgetting it means any token from the same issuer validates on any service in your architecture.
When to choose JWT vs alternatives
Interview cheat sheet
- When asked how JWT authentication works: header (alg and typ), payload (claims), signature (HMAC or RSA over header.payload). Recipient recomputes the signature and compares. No round-trip to the auth server needed.
- When asked about the alg:none vulnerability: the attacker strips the signature and changes the header to alg:none. A naive library reads the algorithm from the token and skips verification. One-line fix: pass the expected algorithm explicitly to verify().
- When asked about stateless authentication tradeoffs: stateless means no session lookup and horizontal scale, but also no revocation. The only lever is expiry time. Saying this immediately signals you understand the core tension.
- When asked about HS256 vs RS256: HS256 is symmetric; any verifier can also forge. RS256 uses a keypair; verifiers hold only the public key and cannot forge. Use RS256 for multi-service architectures where you publish a JWKS endpoint.
- When asked how to revoke a JWT before it expires: short expiry (15 minutes) plus server-tracked refresh tokens is the pragmatic default. For immediate revocation, use a Redis blocklist keyed on the jti claim with TTL matching remaining token lifetime.
- When asked about missing audience validation: a token with aud:payments accepted by the notifications service is a cross-service replay attack. Always pass the expected audience string to verify(). Most libraries make this opt-in.
- When asked whether JWTs are encrypted: no. Base64url is trivially reversible. JWE adds encryption. Never put secrets, passwords, or confidential user data in a plain JWT payload.
- When comparing JWT to session tokens: session tokens are opaque references requiring a server-side lookup; JWTs are self-verifying and stateless. JWTs win on scalability and cross-domain federation; session tokens win on immediate revocation and smaller payload size.
Quick recap
- A JWT is three Base64url-encoded parts (header, payload, signature) joined by dots; the signature makes claims tamper-evident without a server-side session lookup.
- The alg:none vulnerability results from letting the token header choose the verification algorithm; always pass the expected algorithm explicitly to verify() on the verifier side.
- HS256 uses a symmetric secret, so any verifier can also forge; RS256 uses an asymmetric key pair, so verifiers hold only the public key and cannot create tokens.
- JWTs cannot be revoked before expiry without external state; the pragmatic default is short expiry (15 minutes) combined with server-tracked refresh tokens.
- Always validate exp, iss, and aud explicitly; missing aud validation allows any token from the same issuer to authenticate on any service in your architecture.
- When you add a Redis blocklist for immediate revocation, you have reintroduced statefulness and a Redis availability dependency; add it only when your threat model requires faster revocation than your token lifetime provides.
Related concepts
- Microservices: Stateless JWT verification is what makes authentication scale horizontally in microservice architectures, where each service verifies independently without a shared session store central to the request path.
- API Gateway: Verifying JWTs at the gateway centralizes token validation; understanding JWT internals tells you exactly what the gateway checks and what each upstream service must still validate independently (especially audience).
- Hashing and hash functions: HMAC-SHA256 is the mechanism behind HS256; understanding how hash functions work explains why the alg:none attack succeeds and why constant-time signature comparison is required to prevent timing attacks.