The Token Everyone Uses and Few Understand
Every time you log into Auth0, Firebase Auth, Supabase, AWS Cognito, or any modern SaaS app, a JSON Web Token is almost certainly flying across the wire. Per the 2024 State of API Security report, JWT is the authentication mechanism in 68% of public REST APIs and 84% of GraphQL APIs. It is the default choice in NextAuth, Express middleware, Django REST Framework, FastAPI, and Spring Security.
But JWT is also one of the most frequently misused security primitives. The alg:none vulnerability (CVE-2015-9235) broke authentication in dozens of libraries by accepting unsigned tokens. The algorithm confusion attack let attackers sign tokens with the public key as an HMAC secret. Developers routinely stuff tokens with PII, store them in localStorage (exposed to any XSS), set 30-day expiry on access tokens, and forget refresh token rotation entirely.
This guide is a ground-up explanation of JWT as a working engineer needs to understand it. We will dissect a real token byte by byte, compare HS256 / RS256 / ES256, cover the canonical refresh token pattern, enumerate the attacks you need to defend against (with code), and address the perennial "localStorage vs httpOnly cookie" debate with a clear recommendation.
What Is a JWT? (Formal Definition)
A JSON Web Token, defined by RFC 7519, is a compact, URL-safe representation of claims transferred between two parties. In practice, it is three Base64url-encoded strings joined by dots:
header.payload.signature
A real token (decoded below):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNzQ1MzA2ODAwLCJleHAiOjE3NDUzMTA0MDB9.N9U3m8Xo1lJjYcLqKZg_bQ7YR7tR8A2W8a2l8fV3nPA
Decoded:
Header (JSON): { "alg": "HS256", "typ": "JWT" }
Payload (JSON): { "sub": "1234567890", "name": "Alice", "iat": 1745306800, "exp": 1745310400 }
Signature: HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)
Critically, the header and payload are encoded, not encrypted. Anyone who holds the token can read them. The signature is what provides integrity and authenticity — if a single byte of header or payload is changed, the signature no longer verifies.
Anatomy: Header, Payload, Signature
Header. A tiny JSON object declaring the token type and signing algorithm. The two required fields:
- alg: algorithm used to sign the token (HS256, RS256, ES256, EdDSA, or none) - typ: always JWT
Optional: kid (key ID, lets you rotate keys without downtime), jku, x5u.
Payload. A JSON object of claims. Claims fall into three categories per RFC 7519:
Registered (reserved) claims — all optional but standardized: - iss (issuer) — who created the token, e.g. https://auth.example.com - sub (subject) — who the token represents, typically the user ID - aud (audience) — who the token is for, e.g. api.example.com - exp (expiration) — Unix timestamp after which token is invalid - nbf (not before) — Unix timestamp before which token is invalid - iat (issued at) — Unix timestamp when token was created - jti (JWT ID) — unique identifier for revocation tracking
Public claims — registered in the IANA JWT Claims Registry (email, name, preferred_username).
Private claims — application-specific (role, tenant_id, scopes).
Signature. Computed from the encoded header and payload. For HS256:
HMACSHA256(base64url(header) + "." + base64url(payload), secret)
For RS256:
RSASSA-PKCS1-v1_5-SIGN(base64url(header) + "." + base64url(payload), privateKey)
Base64url. Note the "url" — it replaces + with -, / with _, and strips trailing = padding. This makes JWTs safe to put in URLs and HTTP headers without further encoding.
Signing Algorithms: HS256 vs RS256 vs ES256
The alg header determines how the signature is computed and verified. The three production-ready choices:
HS256 (HMAC with SHA-256). Symmetric — same secret signs and verifies. Fast. Simple. Use when issuer and verifier are the same service (e.g., your monolith signing its own session tokens). Secret must be high-entropy (32+ bytes from a CSPRNG), never checked into git, rotated regularly.
RS256 (RSA with SHA-256). Asymmetric — private key signs, public key verifies. The issuer holds the private key; any number of verifying services hold only the public key. Use when multiple services need to verify tokens (microservices, third-party API consumers). Public key is typically distributed via a JWKS endpoint (/.well-known/jwks.json). This is what Auth0, Okta, Google OAuth all use.
ES256 (ECDSA with SHA-256 on P-256). Asymmetric, like RS256, but uses elliptic curves. Signatures are 64 bytes (vs 256 bytes for RS256), keys are much smaller, and signing is faster. Use where token size matters (mobile, IoT) or for modern greenfield systems.
Quick comparison:
Algorithm — HS256: symmetric, simple, single-service • RS256: asymmetric, multi-service, industry standard • ES256: asymmetric, smallest signatures, modern choice
Never use: alg:none (no signature — accept and you are trivially bypassed), RS1 / HS1 (SHA-1 is deprecated).
Key rotation. Always include a kid header claim. Verifiers look up which key to use by kid. This lets you add a new key, issue tokens with it, and retire the old key after existing tokens expire — with zero downtime.
The JWT Authentication Lifecycle
A typical JWT-based auth flow:
1. User submits credentials. POST /login with email and password over HTTPS.
2. Server validates and issues tokens. If valid, the server returns two tokens: - Access token: short-lived (5-15 minutes), JWT, contains user claims - Refresh token: long-lived (7-30 days), opaque random string stored server-side in a database with a hash
3. Client sends access token on each API call.
Authorization: Bearer eyJhbGci...
4. Server validates on every request. Verify signature, check exp, check aud, check iss, check any custom claims. If valid, proceed; if expired, return 401.
5. Client refreshes when access token expires. Send refresh token to /auth/refresh. Server verifies it exists and is unrevoked, then returns a new access token (and optionally a new refresh token — "refresh token rotation").
6. Logout. Invalidate the refresh token server-side. The access token is allowed to expire naturally (or track a blocklist).
This stateless model means any service with the public key can validate tokens without hitting the auth database, making JWT ideal for microservices and serverless. The tradeoff: you cannot easily revoke an access token mid-lifetime — which is why you keep access tokens short-lived.
JWT vs Session Cookies: The Real Comparison
The eternal debate. Here is how they actually compare:
Storage location — JWT: client (localStorage, memory, or cookie) • Session: server (Redis, DB) with session ID in cookie
Scalability — JWT: no server state, horizontal scales trivially • Session: requires shared session store across servers
Revocation — JWT: hard (must use short TTLs + blocklists) • Session: trivial (delete server-side)
Data carried — JWT: claims travel in token, readable by client • Session: ID only, data server-side
Size on wire — JWT: 400-1500 bytes per request • Session: 30-50 byte cookie per request
CSRF vulnerability — JWT in Authorization header: immune • Session cookie: requires CSRF tokens or SameSite
XSS vulnerability — JWT in localStorage: fully exposed • httpOnly session cookie: protected
The pragmatic answer: use JWT access tokens when you have multiple services that need to verify tokens cheaply, or when you are going fully stateless. Use session cookies when you have a traditional monolith and want easy revocation. For single-page apps, the modern recommendation is a hybrid — httpOnly refresh-token cookie, in-memory access token.
JWT Security: Attacks and Defenses
1. alg:none attack. Attacker sets the header to {"alg": "none"} and strips the signature. Vulnerable libraries accept this as a valid "unsigned" token. Defense: explicitly specify allowed algorithms in your verify call. Never trust the alg claim blindly.
// Node.js jsonwebtoken jwt.verify(token, secret, { algorithms: ['HS256'] }); // not just jwt.verify(token, secret)
2. Algorithm confusion (HS/RS). Your server expects RS256 with a public key. Attacker changes the header to HS256 and signs the token using the public key as the HMAC secret. If your verify code does not check the algorithm, it will treat the public key as the HMAC secret and verify successfully. Defense: same as above — lock the algorithm.
3. Weak HS256 secrets. HS256 secrets shorter than 32 bytes can be brute-forced. The token is public, so an attacker runs an offline dictionary attack. Defense: use 32+ byte CSPRNG-generated secrets. openssl rand -base64 48.
4. Key confusion with kid. If kid is used as a file path or SQL query without sanitization, you get LFI / SQLi. Defense: whitelist key IDs or query via parameterized statements.
5. Sensitive data in payload. Payload is base64-encoded, not encrypted. Anyone with the token reads the claims. Defense: never put passwords, SSNs, API keys, or other secrets in the payload. For encrypted tokens, use JWE (RFC 7516) — but prefer opaque tokens for sensitive data.
6. XSS steals tokens from localStorage. A single stored-XSS makes every user's access and refresh token attacker-controlled. Defense: refresh token in httpOnly Secure SameSite=Strict cookie; access token in memory only (JS variable, not localStorage).
7. Long-lived access tokens. A 30-day access token cannot be revoked quickly. Defense: 5-15 minute access token TTL with refresh-token rotation.
8. Missing aud/iss validation. A token issued for service A is accepted by service B. Defense: verify aud matches your service and iss matches your auth server.
JWT in the OAuth 2.0 / OpenID Connect Ecosystem
JWT is an encoding format; OAuth 2.0 is an authorization framework; OpenID Connect (OIDC) is an authentication layer built on OAuth. Many people conflate them.
OAuth 2.0 (RFC 6749) defines flows for obtaining access tokens (authorization code, client credentials, device code). The access token is often — but not required to be — a JWT. Opaque tokens (random strings, introspected via an endpoint) are equally valid under OAuth.
OpenID Connect (OIDC) mandates JWT for its id_token. The id_token proves "this user authenticated" and includes standardized claims (sub, email, email_verified, name, picture). When you click "Sign in with Google," Google returns an id_token as a JWT signed with RS256, verifiable via Google's JWKS at https://www.googleapis.com/oauth2/v3/certs.
Real-world JWT systems you have used:
- Auth0, Okta, Azure AD, AWS Cognito — OIDC providers returning signed JWT id_tokens and access tokens - Firebase Auth — issues custom JWTs signed with RS256 - Supabase — Postgres with RLS policies that read JWT claims via auth.jwt() - Stripe — API keys, not JWT (they chose opaque for revocability) - GitHub Apps — signs installation tokens as JWT-RS256
Common JWT Mistakes (and Fixes)
Mistake 1: Storing JWT in localStorage. Exposed to XSS. Fix: refresh token in httpOnly Secure cookie; access token in memory.
Mistake 2: Using jwt.verify(token, secret) without algorithms option. Opens alg:none and algorithm confusion. Fix: always pass { algorithms: ['RS256'] } explicitly.
Mistake 3: 24-hour or 30-day access tokens. No way to revoke. Fix: 5-15 minute access tokens + refresh rotation.
Mistake 4: Weak HS256 secret (jwtsecret, dev-secret). Trivial to brute-force. Fix: openssl rand -base64 48 and rotate quarterly.
Mistake 5: Storing PII or sensitive claims in payload. Tokens leak in logs, URLs, browser history. Fix: carry only sub + role + minimum metadata. Fetch details from DB if needed.
Mistake 6: Not validating exp. Fix: every JWT library does this by default — do not disable clock skew checks unless you understand why.
Mistake 7: Logging tokens. Access logs, error logs, Sentry, Datadog now contain valid credentials. Fix: redact Authorization headers in logging middleware.
Mistake 8: Skipping aud validation. A token for microservice A accepted by microservice B. Fix: jwt.verify(token, key, { audience: 'api.example.com' }).
JWT Libraries by Language
Production-ready libraries with active maintenance and known-good security defaults:
Node.js — jsonwebtoken (classic), jose (modern, supports JWE/JWS) Python — PyJWT, python-jose, authlib Java — jjwt (JJWT by Les Hazlewood), auth0/java-jwt, Nimbus JOSE + JWT Go — github.com/golang-jwt/jwt (actively maintained fork of dgrijalva/jwt-go after the original was abandoned) Rust — jsonwebtoken, jose PHP — firebase/php-jwt, lcobucci/jwt .NET — Microsoft.IdentityModel.JsonWebTokens (official), System.IdentityModel.Tokens.Jwt
A word of caution: the JWT library ecosystem has a long history of vulnerabilities (alg:none, key confusion, elliptic curve point validation). Always use a maintained library and keep it updated. Never implement JWT yourself — the edge cases will bite you.
Frequently Asked Questions
Are JWTs encrypted?
No. Standard JWTs (JWS — JSON Web Signature) are signed, not encrypted. The payload is Base64url-encoded and fully readable by anyone with the token. For encrypted tokens, use JWE (JSON Web Encryption, RFC 7516), but it is rarely needed — prefer opaque tokens or TLS for confidentiality and keep sensitive data server-side.
How long should my JWT access token be valid?
5-15 minutes is the industry norm in 2026. Short TTL limits blast radius of a stolen token and removes the need for an expensive revocation mechanism. Pair with a longer-lived (7-30 day) refresh token that you can revoke server-side.
What is the difference between an access token and a refresh token?
An access token is presented on every API request; it is short-lived and stateless (usually JWT). A refresh token is presented only to the auth server's /refresh endpoint; it is long-lived and revocable (usually opaque, stored server-side). This split gives you both stateless scalability and the ability to revoke sessions.
Should I store JWT in localStorage or cookies?
Neither, naively. Best practice in 2026: the refresh token goes in an httpOnly Secure SameSite=Strict cookie (inaccessible to JavaScript, immune to XSS, protected against CSRF by SameSite). The access token is held in memory (a JavaScript variable), never in localStorage. After page refresh, silently refresh using the cookie.
Can I revoke a JWT before it expires?
Not directly. Two patterns: (1) keep a server-side blocklist of revoked jti values checked on each request — reintroduces state, (2) keep access tokens short-lived and revoke the refresh token. Pattern 2 is standard.
What makes a JWT secret strong?
For HS256: minimum 32 bytes (256 bits) of cryptographically random data. Use openssl rand -base64 48 or Node's crypto.randomBytes(48).toString('base64'). Never use a human-chosen string. For RS256: 2048-bit RSA minimum (4096-bit preferred), or switch to ES256 / EdDSA.
Is JWT overkill for a small app?
Often, yes. A simple monolith with server-side sessions (express-session + Redis, Django's built-in sessions) is simpler, easier to revoke, and has fewer security footguns. Reach for JWT when you need stateless verification across multiple services or third-party API consumers.
What is the size limit on a JWT?
There is no protocol limit, but practical HTTP header limits apply. Most servers (Nginx, Apache) cap header size at 8 KB by default. Keep tokens under 4 KB to be safe. If you are approaching that, you are putting too much in the payload.
Summary and Next Steps
JWT is a powerful but sharp tool. Used correctly — short-lived access tokens, refresh token rotation, RS256 or ES256 with kid, explicit algorithm validation, httpOnly cookies for refresh tokens — it gives you stateless, scalable authentication that works across microservices and clients.
Want to decode and inspect a JWT right now in your browser, fully client-side, with no data ever leaving your device? Try our JWT decoder alongside our full developer toolkit:
https://stringtoolsapp.com
Related Tools
- Base64 Encoder/Decoder — for decoding JWT header and payload - Hash Generator — test HMAC-SHA256 signatures - JSON Formatter — prettify decoded JWT payloads - Password Generator — generate HS256 secrets
Explore all tools: https://stringtoolsapp.com