Structure
A JSON Web Token (JWT) is three base64url-encoded segments separated by dots: a header (naming the signing algorithm), a payload (the claims — arbitrary data such as user ID and expiry), and a signature over the first two segments. The signature can be produced with a symmetric HMAC (HS256) or an asymmetric algorithm like RSA or ECDSA (RS256, ES256).
Anatomy of a JWT
Header (base64url)
{"alg": "HS256", "typ": "JWT"} — names the signing algorithm.
Payload (base64url)
{"sub": "user123", "exp": 1735689600, ...} — the claims. Encoded, not encrypted.
Signature
HMAC or RSA/ECDSA signature over "header.payload", proving neither was altered.
A real token, decoded
Here's an actual HS256-signed JWT encoding the claims {"sub": "1234567890", "name": "Alice", "iat": 1516239022}, signed with a symmetric secret. Each of the three segments below decodes with ordinary base64url — paste the first two into any base64 decoder and you'll get readable JSON back; only the third segment (the signature) is opaque, because it's the output of an HMAC, not an encoding of anything.
A real HS256 token, segment by segment
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Decodes to {"alg":"HS256","typ":"JWT"}
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNTE2MjM5MDIyfQ
Decodes to {"sub":"1234567890","name":"Alice","iat":1516239022}
gJh7jdj0jULbD61KFeWRh9Ux05MSQMND99uj_GbqN_k
HMAC-SHA256 over the first two segments, using the server's secret key
What the signature does and doesn't guarantee
Verifying a JWT's signature confirms the claims haven't been altered since signing and that they were signed by a holder of the corresponding key — it says nothing about whether the token has since been revoked or is still meant to be valid, which is why expiry (exp) claims and short lifetimes matter. The payload is only encoded, not encrypted: anyone can base64-decode it and read the claims, so secrets never belong there.
JWTs vs. opaque session tokens
The alternative to a JWT is an opaque session token: a random string that means nothing on its own, looked up in a server-side database on every request. The trade-off between them is really a trade-off about where state lives.
Opaque session token
- •Server looks up session state in a database on every request
- •Revoking access is instant — just delete the server-side record
- •Doesn't scale as easily across independent services without a shared session store
JWT
- •Self-contained — any service holding the public key (or shared secret) can verify it with no database lookup
- •Scales well across microservices with no shared state
- •Can't be revoked before its expiry without extra infrastructure (a blocklist, short lifetimes, etc.)
Well-known implementation pitfalls
The "alg: none" vulnerability let an attacker submit a token whose header claims no signature algorithm was used, and some early libraries would accept it as valid — effectively an unsigned token treated as trusted.
Algorithm confusion: turning a public key into an HMAC secret
A more subtle attack targets servers that support both RS256 (asymmetric) and HS256 (symmetric) verification. If the verifier trusts the algorithm named in the token's own header rather than pinning the algorithm it expects, an attacker can take a server's public RSA key (which is, by design, public) and use it as the secret for an HS256-signed forged token. A verifier that blindly follows the header's "alg": "HS256" will compute the HMAC using that public key as the secret — and the attacker, who also has that public key, can compute the exact same HMAC.
Algorithm confusion attack
- 1
Attacker obtains the server's public key
RSA/ECDSA public keys used for RS256/ES256 verification are, by design, not secret.
- 2
Attacker crafts a forged token
They write whatever claims they want, set the header to "alg": "HS256", and sign it with HMAC using the public key as the HMAC secret.
- 3
Vulnerable server verifies
If the server reads "alg" from the token and uses HS256 verification with the same public key value, the forged signature checks out.
- 4
The fix
A correctly implemented verifier pins the expected algorithm itself and rejects any token that doesn't match — never trusting the header's own claim.