A JSON Web Token (JWT) is a compact string that two systems pass around to share information. Think of the short token your server returns right after login. This guide walks through what a JWT looks like, how to verify one correctly, and the security pitfalls that trip up most teams. RFC 7519 is long; we'll focus on the parts you actually use.
What problem JWT solves
Traditional session auth stores a session ID on the server and looks it up in a database or cache on every request. As traffic grows, the session store becomes a bottleneck, and scaling across multiple servers requires sticky sessions or a shared cache.
JWT packs the user information into the token itself and signs it. The server only verifies the signature — no lookup needed. The same token flows through microservices, carrying identity along. The trade-off: once issued, a JWT cannot be revoked easily before its expiry — this is the central tension in any JWT design.
Token structure — three pieces
A JWT is three Base64URL-encoded segments separated by dots (.):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0In0.SflKxw...- Header — Base64URL-encoded JSON describing the algorithm (
alg) and type (typ). Example:{"alg":"HS256","typ":"JWT"} - Payload — Base64URL-encoded JSON holding claims: user id, expiry, permissions, and so on.
- Signature — header + payload signed with a secret or private key. Detects tampering.
Base64URL is almost the same as standard Base64 but URL-safe: + becomes -, / becomes _, and trailing = padding is dropped. Crucially this is encoding, not encryption — anyone can decode the payload with Base64 Encode / Decode. Never put secrets in the payload.
Standard claims
RFC 7519 defines short keys for common claims to keep tokens small.
| Key | Name | Meaning |
|---|---|---|
iss | Issuer | Who issued the token (auth server URL) |
sub | Subject | Who the token is about (usually a user id) |
aud | Audience | Who is allowed to consume it (API name) |
exp | Expiration | Expiry time (Unix epoch seconds) |
nbf | Not Before | Invalid before this time |
iat | Issued At | When the token was issued |
jti | JWT ID | Unique token id (for revocation lists) |
You can add custom claims (role, email, …) freely. Watch the size though — payloads ride along on every request. Keep them under 200–500 bytes if possible.
Algorithms — HS256 vs RS256
The alg header decides the signing method. In practice you will see one of two:
- HS256 (HMAC-SHA256) — A shared secret signs and verifies. Fast and simple for a single backend. Leak the secret and anyone can forge tokens. This is plain HMAC Generator under the hood.
- RS256 (RSA-SHA256) — Asymmetric. The issuer signs with a private key, verifiers use a public key. Public keys can be distributed safely, so multiple services can verify without sharing a secret. Default for OAuth/OIDC providers like Auth0 or Cognito.
ES256 (ECDSA) is also asymmetric but with smaller keys and signatures — a fit for mobile or constrained environments.
Verification — the mandatory checks
Tools like JWT Decoder show the payload instantly. But decoding is not verifying. Before accepting a token, your server must:
- Verify the signature with the declared algorithm and your key.
- Enforce an algorithm allowlist. Never trust the
algin the header alone — this is the entry point for the famousalg: noneand HS/RS confusion attacks. - Check
expagainst the current time, usually with ±30s leeway for clock skew. - Validate
issandaud— the token came from the right issuer and is intended for your service.
Most libraries (Node's jsonwebtoken, Python's PyJWT, Go's golang-jwt) handle these checks when you pass verify(token, secret, {algorithms: ["HS256"]}). Passing an algorithm list is not optional — defaults have historically been unsafe.
Common pitfalls
1. Storing secrets in the payload
The payload is plain encoding. Passwords, national IDs, API keys — never. Keep the user id and roles, look up sensitive data on the server.
2. Weak signing key
An HS256 secret like "changeme" is brute-forceable in seconds. Use at least 256 bits (32 bytes) of random data — openssl rand -base64 32 or yutils' Password Generator. Never commit secrets — a git search will find them.
3. No revocation path
JWT's stateless nature breaks "log out immediately on all devices". Two common fixes:
- Short-lived access + refresh tokens — access expires in 5–15 minutes, refresh tokens live in the database and can be revoked.
- Revocation list — store
jtior user ids to deny. You lose statelessness, but you regain control.
4. The alg: none attack
Old JWT libraries would skip signature verification if alg was none. An attacker rewrites the header to {"alg":"none"} and forges the payload. Most libraries patched this between 2015–2019, but legacy services are still out there.
5. HS256 ↔ RS256 confusion
A server expects RS256, but accepts whatever the header says. An attacker switches the header to HS256 and uses the public key as the HMAC secret. Public keys are, well, public — so anyone can sign. The only defense is an explicit algorithm allowlist.
6. Clock skew
Distributed servers drift by a few seconds. A token issued a moment ago can be rejected on exp or nbf. Use NTP and a ±30s leeway.
Where to store JWTs
In the browser you have three options:
- localStorage — Accessible from JS. A single XSS steals every token. Simple but risky.
- HttpOnly + Secure cookies — Not accessible from JS, so XSS can't read them, but you need CSRF protection — SameSite plus a CSRF token.
- In-memory variable — Lost on reload, but safest. Common pattern: short-lived access token in memory, refresh token in an HttpOnly cookie.
Try it
The fastest way to understand JWT is to encode and decode one.
- JWT Encoder (HMAC) — build a token from a payload, secret, and algorithm (HS256/384/512).
- JWT Decoder — paste a token, see its header and payload. All processing happens in the browser, so you never ship secrets to a third-party site.
- HMAC Generator — run HMAC-SHA256 manually on the
header.payloadstring and watch the signature match the third segment.
Recap
- JWT = Base64URL(header).Base64URL(payload).signature
- The payload is encoded, not encrypted — never store secrets in it.
- On verify, enforce an algorithm allowlist and check
exp,iss,aud. - Use a 256-bit random secret stored outside the codebase.
- Short-lived access tokens plus refresh tokens give you both statelessness and a revocation path.