The Complete JWT Guide: Internals, 5 Security Pitfalls, and Production Best Practices
Anyone who has built a login system has seen JWT — that random-looking eyJhbGci...xxxx.yyyy.zzzz string. The pitch is irresistible: stateless authentication, no server-side sessions, scale horizontally as much as you want.
But JWT is also famously trap-dense. OWASP keeps JWT-related issues high on common vulnerability lists, and the last decade has seen a steady stream of algorithm bugs, signature bypasses, and key leakage incidents. Plenty of teams discover only after launch that writing JWT code is easy; writing secure JWT code is hard.
This article distills everything that actually matters about JWT: the structure, algorithm choices, the five major security pitfalls, how to design refresh tokens — and most importantly, when you shouldn't use JWT at all.
What Is JWT: Three-Part Structure
A JWT looks like this:
Three parts separated by dots: Header.Payload.Signature.
Header
Base64URL-decoded, it's JSON:
alg: signing algorithmtyp: alwaysJWT- Optional
kid: key ID, used during key rotation
Payload
A collection of claims:
Standard claims (recommended):
| Field | Meaning | Required |
|---|---|---|
iss | Issuer | Recommended |
sub | Subject (user ID) | Recommended |
aud | Audience | Recommended |
exp | Expiration timestamp | Required |
iat | Issued at | Recommended |
nbf | Not before | Optional |
jti | Unique JWT ID (for revocation) | Optional |
Payload is Base64URL encoded, not encrypted. Anyone who gets the JWT string can decode and see everything in it. Never put passwords, credit card numbers, or private keys into the payload.
Signature
The signature is computed as:
The signature guarantees:
- Integrity — modifying the payload breaks signature verification
- Authenticity — only the holder of the secret can produce a valid token
Algorithm Choice: HS256 vs RS256 vs ES256
| Algorithm | Type | Key | Use Case |
|---|---|---|---|
| HS256 | HMAC + SHA-256 | Single symmetric key | Single service, internal use |
| HS384 / HS512 | Higher-strength HMAC variants | Same | Same |
| RS256 | RSA + SHA-256 | Private signs, public verifies | Multi-service, public verification |
| ES256 | ECDSA + SHA-256 | Elliptic curve | Same as RS256, faster, shorter tokens |
| EdDSA | Ed25519 | Modern elliptic curve | Recommended for new projects |
| none | No signature | None | Never use (see below) |
How to Choose
- Single internal service: HS256 is plenty and simplest to deploy
- Microservices / third-party integrations: RS256 or ES256 — issuer signs with private key, all verifiers use the public key, key distribution is far safer
- New projects: prefer EdDSA / ES256 (better performance, smaller tokens)
5 Security Pitfalls That Actually Bite in Production
Pitfall 1: alg=none Bypass
JWT spec includes a peculiar algorithm called none, meaning "no signature". Early libraries would happily accept it — meaning an attacker can craft:
with any payload and no signature, and verification passes.
Fix:
Major libraries now reject none by default, but always double-check when inheriting old code or using obscure libraries.
Pitfall 2: Algorithm Confusion (HS256 vs RS256)
Server uses RS256, public key is published. Attacker changes the header to:
and signs the token using the public key as the HMAC secret. If your server looks like this:
the attacker can forge arbitrary tokens using the (public) public key.
Fix:
Never select the verification algorithm based on the token's own header.
Pitfall 3: Weak Secrets
If your HS256 secret is secret123 or mysecret, it can be brute-forced offline:
A medium-strength password falls in about a minute.
Fix:
-
For HS256/HS384/HS512, use at least 256-bit (32-byte) random keys:
-
Never hardcode in source or config files — load from environment variables or a key management service (KMS, Vault)
-
Don't reuse passwords or API keys as JWT secrets
Pitfall 4: Missing or Unverified Expiration
Even worse: not setting exp at all when issuing. Tokens live forever, and one leak compromises everything.
Fix:
- Always set exp, keep it short (15 min - 2 h is typical)
- Use refresh tokens for longer sessions (see below)
- Force
verify_exp=Trueduring verification (library default — don't manually disable)
Pitfall 5: JWTs Cannot Be Revoked
JWT is stateless by design — as long as the signature is valid and exp hasn't passed, the server accepts it. The problem:
- User changes password → old token still works
- User logs out → old token still works
- Account compromise suspected → old token still works
Fixes (in order of preference):
- Short exp + refresh tokens — access token expires in 15 min, blast radius is bounded
- Blacklist (jti in Redis) — write jti to blacklist on logout, check on every request — but this breaks the "stateless" benefit
- Bump key/token version on password change — add
tokenVersionto payload, increment on password change; only useful for "log everyone out" scenarios
Refresh Token Design Patterns
Industry standard: Access Token + Refresh Token, two-token model.
Key Design Decisions
1. Use JWT for access tokens, random strings for refresh tokens
Access tokens are short-lived and stateless (performance first); refresh tokens are long-lived and revocable (security first).
2. Refresh tokens must be stored in a database
Store in Redis or a DB table, recording:
| Field | Purpose |
|---|---|
| token_hash | SHA-256 hash of the token (never plain) |
| user_id | Owner |
| device_info | Device / IP / UA (for audit) |
| expires_at | Expiry time |
| revoked | Revocation flag |
3. Refresh Token Rotation
Every time a refresh token is exchanged for a new access token, issue a new refresh token and invalidate the old one. If the old refresh token is leaked, the moment the attacker uses it the real user's next request triggers a "token already used" alarm.
4. Where to Store the Refresh Token
| Location | Pros | Cons |
|---|---|---|
| HttpOnly cookie | XSS-resistant | Must handle CSRF |
| localStorage | Simple to implement | Stealable via XSS |
| Native app secure storage | iOS Keychain / Android Keystore | App only |
Recommended: HttpOnly + Secure + SameSite=Strict cookies on the web, platform-specific secure storage in apps.
Working Code Examples
Node.js (jsonwebtoken)
Python (pyjwt)
Debugging Techniques
Decode the Payload
Don't count Base64 by eye — use a tool:
-
The site's JWT Decoder decodes header and payload with one click
-
jqcombined withbase64:
Verify Signatures Online
The JWT Decoder also supports entering the secret to verify the signature — extremely handy for debugging. Never paste production tokens into third-party online tools.
Trace the Token Lifecycle
Add logging at key events:
When things go wrong, trace by jti for instant clarity.
When NOT to Use JWT
JWT isn't a silver bullet. For these scenarios, classic sessions are better:
| Scenario | Why Sessions Win |
|---|---|
| Monolithic app, single data center | Sessions are simple and reliable; revoke = delete |
| Frequently-changing user state (permissions, subscription) | JWT caches the data; changes don't propagate immediately |
| Strict audit / revocation needs (finance, healthcare) | Sessions are server-controlled; JWTs are hard to revoke |
| Tokens must carry lots of user data | JWTs grow large, every request pays the cost |
| Extreme performance needs (high-throughput API) | HMAC verification still costs CPU; Redis session lookup is faster |
JWT genuinely shines for:
- Cross-service / microservice calls (no central auth service)
- Short-lived one-shot tokens (email verification, password reset links)
- ID tokens in OAuth / OIDC flows
- Mobile long sessions (paired with refresh tokens)
Summary
JWT done right is elegant. JWT done wrong is a timed bomb. Keep these in your head:
- Always pin the algorithm, disable none, never dynamic alg
- Secrets are at least 256-bit random, loaded from env or KMS
- exp is mandatory, short-lived (15-minute range)
- Sensitive data stays out of the payload — it's Base64 encoded, not encrypted
- Pair with refresh tokens for long sessions, pair with a database for revocation
- Use sessions when you don't need statelessness — don't reach for JWT just because it looks modern
After writing code, decode tokens with the JWT Decoder to sanity-check the header and payload, generate strong random keys with the Hash Generator — that combination eliminates roughly 80% of common JWT misconfigurations.