JWTs Are Bearer Tokens β Treat Them Like Cash
A JWT is just a signed, base64-encoded JSON object. There's no magic security in the format itself β it's only as safe as your signing key, your validation logic, and where you store it. Most JWT incidents come from skipping one of those three.
- Your token lifetime is 30 days βbecause logging in is annoyingβ
- JWTs sit in localStorage where any XSS payload can grab them
- You verify the signature but never check
iss,aud, orexp - You have no way to revoke a token before it expires
The fix is short-lived access tokens, refresh tokens stored differently, and validating every standard claim β not just the signature.
Anatomy of a JWT
header.payload.signature
β β β
β β βββ HMAC or RSA/ECDSA over (header || '.' || payload)
β βββ claims: sub, iss, aud, exp, iat, nbf, jti, + your custom data
βββ { "alg": "RS256", "typ": "JWT", "kid": "2026-04" }Everything before the second dot is readable by anyone. JWT is not encryption. If you need confidentiality, use JWE (or skip JWTs and use opaque session tokens with a server-side store).
HS256 vs RS256 / ES256
HS256uses a shared secret for both signing and verifying β fine when one service issues and verifies its own tokens. The moment a second service needs to verify, you have to share the secret, and that's where it leaks. Use RS256 orES256 (asymmetric): the issuer signs with a private key, verifiers fetch the public key from JWKS, no secret distribution.
// Verifying an RS256 token with jose (the maintained JS library)
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://issuer.example/.well-known/jwks.json'));
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://issuer.example', // pinning iss
audience: 'urn:my-api', // pinning aud β prevents token reuse across services
algorithms: ['RS256'], // EXPLICIT β blocks the 'none' attack
clockTolerance: '30s',
});The Classic JWT Attacks
The alg: none attack
Old libraries accepted { "alg": "none" } and skipped signature verification entirely. Always pass an explicitalgorithms allowlist to your verifier. Never trust the algorithm field in the header to drive a switch statement.
Algorithm confusion (RS256 β HS256)
Attacker takes your RS256 public key (it's public!), uses it as an HS256 secret, and forges tokens. Libraries that picked algorithm from the header were vulnerable. Again β explicit allowlist on the verifier side.
Key rotation without kid
Every token header should include a kid(key ID). Without it you can't rotate keys safely. JWKS endpoints publish multiple keys at once; the verifier picks by kid; you can publish a new key, switch issuance to it, and retire the old key once all tokens expire.
sequenceDiagram
participant C as Client
participant A as Auth Server
participant API as API
participant JWKS as JWKS endpoint
C->>A: POST /token (creds)
A-->>C: access_token (5m) + refresh_token (7d, rotating)
C->>API: GET /resource (Bearer access_token)
API->>JWKS: GET /.well-known/jwks.json (cached)
JWKS-->>API: { keys: [{ kid, n, e }, ...] }
API->>API: verify sig, iss, aud, exp, nbf
API-->>C: 200
Access Tokens vs Refresh Tokens
Access tokens are short-lived (5β15 minutes) and sent on every request. Refresh tokens are long-lived (days), used once, and rotated on every exchange. Refresh-token rotation with reuse detection is the table-stakes defense against stolen tokens β if a refresh token is used twice, you invalidate the entire family and force re-auth.
Where to Store Tokens
| Storage | Pros | Cons |
|---|---|---|
localStorage | Easy, survives refresh | Any XSS = full token theft |
sessionStorage | Same as above + tab-scoped | Same XSS exposure |
| JS-readable cookie | Sent automatically | Same XSS exposure, plus CSRF |
| HttpOnly, Secure, SameSite cookie | XSS-safe; CSRF mitigated by SameSite=Lax | Needs same-origin or careful CORS |
| In-memory only | Lost on refresh β refresh via cookie | Most complex; safest for SPAs |
For browser apps, the modern recommendation is: refresh token in an HttpOnly; Secure; SameSite=Strict cookie scoped to the auth endpoint, access token held in memory only. Mobile apps use the OS keychain.
Revocation
JWTs are stateless β the server can't βinvalidateβ one without keeping state. Three pragmatic options:
- Short lifetime + refresh rotation β the default. Compromise window = access-token lifetime.
- JTI denylist β Redis set of revoked
jtis, checked on every verify. Adds a hop but bounded by token TTL. - Per-user version claim β store a
tokenVersionon the user; include it in the JWT; bump it on logout-all.
What to Validate on Every Request
// The full checklist your verifier must enforce
1. Signature is valid with a key from your trusted set
2. alg matches your allowlist (never 'none')
3. exp is in the future (with small clock skew tolerance)
4. nbf and iat are reasonable
5. iss matches the expected issuer
6. aud contains your service identifier
7. sub is present
8. (Optional) jti is not in the revocation list
9. (Optional) Scopes / roles match the route requirementsPitfalls
Putting too much in the token
JWTs grow into every header and log line. Keep payloads small: sub, scopes, tenant, and an opaque user reference. Fetch the rest from your user store.
Trusting exp alone for security boundaries
Time-based expiry is necessary but not sufficient. For sensitive actions (password change, payment), require recent authentication regardless of token age (auth_time claim).
Forgetting that base64 is not encryption
Putting a user's email, phone, and role in the payload means anyone who logs the token sees them all. Treat the JWT payload as if it were printed on the side of a bus.

