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.

πŸ€” Sound familiar?
  • 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, or exp
  • 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

StorageProsCons
localStorageEasy, survives refreshAny XSS = full token theft
sessionStorageSame as above + tab-scopedSame XSS exposure
JS-readable cookieSent automaticallySame XSS exposure, plus CSRF
HttpOnly, Secure, SameSite cookieXSS-safe; CSRF mitigated by SameSite=LaxNeeds same-origin or careful CORS
In-memory onlyLost on refresh β€” refresh via cookieMost 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 tokenVersion on 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 requirements

Pitfalls

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.