JWTs Explained: Header, Payload, Signature and Security Pitfalls
A JSON Web Token (JWT) is a compact, URL-safe string used to prove identity or convey claims between two parties — no database lookup required on every request. They're everywhere: OAuth 2.0, REST APIs, single-page apps. But the format is surprisingly easy to misuse, and the mistakes range from embarrassing to catastrophic. This guide walks through every moving part and the pitfalls you actually need to care about.
The Three Parts of a JWT
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcxOTAwMDAwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Three base64url-encoded segments separated by dots: header.payload.signature. None of it is encrypted by default — paste any JWT into our decoder and you can read the contents immediately.
- Header — a JSON object declaring the token type (
typ: JWT) and the signing algorithm (alg: HS256,RS256, etc.). - Payload — a JSON object carrying claims: registered claims like
sub(subject),iss(issuer),exp(expiry),iat(issued-at), plus any custom claims your application needs (role,plan,org_id). - Signature — produced by signing
base64url(header) + "." + base64url(payload)with a secret or private key. This is what prevents tampering. If you change even one character in the payload, the signature stops matching.
The critical insight: the payload is readable by anyone who has the token. The signature only proves it wasn't modified — it does not hide the data inside.
Signing vs Encryption — a Crucial Distinction
Most JWTs are signed (JWS — JSON Web Signature), not encrypted (JWE — JSON Web Encryption). Signed tokens confirm integrity and authenticity. Encrypted tokens (alg: RSA-OAEP, enc: A256GCM) additionally hide the payload from third parties.
| Property | Signed JWT (JWS) | Encrypted JWT (JWE) |
|---|---|---|
| Payload readable by anyone? | Yes — just base64url-decode | No — ciphertext without the key |
| Tamper-evident? | Yes | Yes |
| Common use case | Authentication, authorization | Sensitive claim transmission |
| Typical alg values | HS256, RS256, ES256 | RSA-OAEP, ECDH-ES |
| Dot segments | 3 | 5 |
Rule of thumb: never put a password, credit card number, or secret in a standard JWT payload unless you are using JWE. User IDs, roles, and expiry timestamps are fine. SSNs and medical records are not.
For signing algorithms, prefer RS256 (asymmetric RSA) or ES256 (ECDSA) over HS256 (HMAC-SHA256) in any system with multiple services. With HS256, every service that verifies tokens must also know the secret — meaning any of them can forge tokens. With RS256, the private key stays on the auth server; everyone else only needs the public key.
The alg=none Attack
This is one of the most notorious JWT vulnerabilities, documented in CVE-2015-9235 and affecting multiple libraries. Here's the attack:
- The attacker takes a valid token and decodes the header.
- They change
algtononeand the payload to whatever they want (e.g.,role: admin). - They re-encode header and payload and append an empty signature:
header.payload. - A naive library sees
alg: none, skips signature verification, and accepts the token as valid.
The fix is simple and non-negotiable: always explicitly whitelist the algorithms your server will accept. Never derive the accepted algorithm from the token header itself. In most libraries this looks like:
jwt.verify(token, secret, { algorithms: ['HS256'] })
A correctly hardened verifier rejects any token whose header specifies an algorithm not on your approved list — including none, None, NONE, and every other casing variant.
Expiry Claims: exp, nbf, and iat
Three registered claims control a token's time window:
exp(Expiration Time) — Unix timestamp (seconds since epoch) after which the token must be rejected. Always set this. Access tokens typically expire in 15 minutes to 1 hour; refresh tokens in hours to days.nbf(Not Before) — the token is invalid before this timestamp. Useful for pre-issued tokens that should only activate at a future time.iat(Issued At) — when the token was created. Lets you compute token age and enforce maximum lifetime policies independently ofexp.
Short expiry limits the damage window if a token is stolen, but creates UX friction. The standard solution is a refresh token pair: a short-lived access token (15–60 min) plus a long-lived refresh token stored securely. When the access token expires, the client silently exchanges the refresh token for a new pair.
One subtlety: clock skew between servers. A token issued at server A may arrive at server B a few seconds before B's clock catches up. Most libraries accept a small leeway (typically 0–60 seconds) to absorb this. Don't set leeway to 10 minutes — that defeats the purpose.
Storage: Cookie vs localStorage
Where you store a JWT in the browser is a security decision with real trade-offs:
| Storage | XSS risk | CSRF risk | Accessible to JS? | Recommended for |
|---|---|---|---|---|
localStorage | High — any injected script reads it | None — not sent automatically | Yes | Low-sensitivity SPAs with strict CSP |
sessionStorage | High — same as localStorage | None | Yes | Short-lived sessions, tab-scoped |
HttpOnly cookie | Low — JS cannot read it | Medium — sent on every request | No | Most web apps |
HttpOnly + SameSite=Strict cookie | Low | Low — cross-site requests blocked | No | Best practice default |
The consensus in 2024: use HttpOnly; Secure; SameSite=Strict cookies for tokens whenever possible. They're invisible to JavaScript, which eliminates the entire class of XSS-based token theft. Combine with CSRF tokens or the SameSite attribute for CSRF protection.
If you must use localStorage (e.g., a native mobile WebView that doesn't support cookies well), enforce a strict Content Security Policy and keep access token lifetimes short — under 15 minutes.
Decoding a JWT by Hand
You don't need a library to read a JWT. The payload segment is just base64url-encoded JSON. Pad it to a multiple of 4 characters, substitute - with + and _ with /, then base64-decode. In a browser console:
JSON.parse(atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')))
Our JWT Decoder tool does exactly this — plus it highlights expiry status, algorithm warnings, and claim structure — without sending your token anywhere. Paste and inspect entirely in-browser.
For verification, you do need the secret or public key. Never share your signing secret with a third-party decoder site. Use a local tool or our client-side decoder, which never touches a server.
Frequently asked questions
Is a JWT the same as a session cookie?+
No. A traditional session cookie holds an opaque ID that the server looks up in a database to find the session. A JWT encodes the session data directly inside the token — the server verifies the signature and trusts the claims without any database lookup. This makes JWTs stateless but harder to revoke before expiry.
Can I put a user's password or secret data in a JWT?+
Only if you use an encrypted JWT (JWE). Standard JWTs are signed, not encrypted — the payload is base64url-encoded and readable by anyone who holds the token. Store only non-sensitive identifiers and access-control claims in a regular signed JWT.
How do I invalidate a JWT before it expires?+
JWTs are stateless, so there is no built-in revocation. Common patterns are: maintaining a token blocklist (a small Redis set of revoked JTI claim values), using very short expiry windows paired with refresh tokens, or switching to opaque tokens where revocation is trivial.
What is the difference between HS256 and RS256?+
HS256 uses a shared HMAC secret — the same key signs and verifies, so every verifying service holds a secret that can also forge tokens. RS256 uses an RSA key pair: the private key signs tokens (kept only on the auth server), and the public key verifies them (safe to distribute). RS256 is preferred in multi-service architectures.
Does the JWT Decoder tool send my token to a server?+
No. The decoder runs entirely in your browser using client-side JavaScript. Your token is never transmitted — decoding is just base64url parsing, which requires no network call. This makes it safe to paste real tokens for debugging.