JWT Authentication
Deep Dive
JWTs are everywhere โ and so are JWT implementation bugs. This guide covers the full token lifecycle: how they're structured, how to validate them correctly, when to use refresh tokens, and the security pitfalls that catch developers off guard.
By the numbers
Anatomy of a JWT โ decoded
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJBbGljZSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxOTAwMDAwMCwiZXhwIjoxNzE5MDAzNjAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
โ Header
{ "alg": "HS256", "typ": "JWT" }Declares the token type and signing algorithm.
โ Payload
{ "sub": "user_123", "name": "Alice", "role": "admin", "iat": 1719000000, "exp": 1719003600 }The claims โ who the token is for and what it asserts.
โ Signature
HMACSHA256(base64(header) + "." + base64(payload), secret)Cryptographic proof the token hasn't been tampered with.
โ ๏ธ The payload is Base64-encoded, not encrypted. Never put secrets in JWT claims.
What Is a JWT?
A JSON Web Token is a compact, self-contained string that carries information about a user or system in a format that can be independently verified. The key word is self-contained: unlike session cookies that require a database lookup on every request, a JWT encodes the user's identity and permissions directly inside the token โ and proves they haven't been altered using a cryptographic signature.
When a user logs in, your server creates a JWT signed with a secret key (or private key) and sends it back. On every subsequent request, the client includes that token. The server verifies the signature โ without touching a database โ and trusts the claims inside. This makes JWTs particularly attractive for stateless architectures, APIs, and microservices where you want to avoid session storage.
The trade-off: JWTs can't be invalidated before they expire. If you issue a token and the user logs out or their permissions change, the token is still technically valid until its expiry. Managing this is the central challenge of JWT-based auth systems.
| Aspect | Session Cookies | JWTs |
|---|---|---|
| Storage | Server-side (DB or cache) | Client-side (token itself) |
| Scalability | Requires shared session store across servers | Stateless โ any server can verify |
| Revocation | Instant โ delete the session | Difficult โ must wait for expiry or use blocklist |
| Payload | Session ID only | Claims embedded (user ID, roles, etc.) |
| Best for | Monoliths, traditional web apps | APIs, microservices, mobile apps |
JWT Structure in Detail
Every JWT is three Base64URL-encoded JSON objects joined by dots. Understanding what lives in each part โ and what the limitations are โ is fundamental to implementing JWTs correctly.
Header
algThe signing algorithm โ e.g. HS256, RS256, ES256. This tells the receiver which algorithm to use when verifying the signature.
typToken type โ almost always "JWT". Some implementations also use "at+JWT" for access tokens per newer standards.
โ Never trust the "alg" field without validating it server-side. The alg:none attack exploits servers that accept whatever algorithm the token claims to use.
Payload
subSubject โ uniquely identifies the principal this token refers to. Typically a user ID.
issIssuer โ identifies who created the token. Useful in multi-issuer environments.
audAudience โ identifies who this token is intended for. Validate this to prevent token reuse across services.
iatIssued at โ Unix timestamp of when the token was created.
expExpiry โ Unix timestamp after which the token must be rejected. Always include this.
nbfNot before โ token is invalid before this timestamp. Optional but useful for delayed activation.
โ The payload is only Base64 encoded โ anyone can decode it. Never store passwords, secrets, or sensitive PII in JWT claims.
Signature
ConstructionALGORITHM(base64url(header) + '.' + base64url(payload), secret_or_private_key)
PurposeGuarantees the header and payload haven't been modified since the token was issued. Without a valid signature, the token is worthless.
โ The signature only proves integrity โ not confidentiality. If you need the payload to be unreadable, use JWE (JSON Web Encryption) instead of plain JWTs.
Signing Algorithms Compared
The algorithm you choose affects your security model, your infrastructure requirements, and what attack surface you expose. There are two families worth understanding.
Symmetric (HMAC)
HS256HS384HS512One shared secret key โ used to both sign and verify.
Pros
- โSimple to implement
- โFast to verify
- โNo key infrastructure needed
Cons
- โSecret must be shared with every verifying service
- โCompromise of one service exposes the key to all
๐ก Single-service APIs, internal microservices where you control all verifiers.
Asymmetric (RSA / ECDSA)
RS256RS384ES256ES384Private key signs; public key verifies. The private key never leaves your auth server.
Pros
- โServices only need the public key to verify
- โCompromise of a consumer service doesn't expose signing key
- โPublic key can be published via JWKS endpoint
Cons
- โMore complex key management
- โSlightly slower verification
- โRSA keys should be 2048+ bits
๐ก Multi-service architectures, third-party token consumers, OAuth/OIDC implementations.
๐ซ Never use the "none" algorithm in production
alg: none, meaning an unsigned token. Some libraries, if not configured correctly, will accept these tokens as valid. Always explicitly allowlist the algorithms your server will accept and reject anything else โ including none.The Full Token Lifecycle
Understanding the end-to-end lifecycle โ from user login to token expiry โ helps you design auth flows that are both secure and usable.
User authenticates
The user submits credentials (username/password, OAuth code, biometric, etc.) to your auth endpoint. You verify those credentials against your user store.
POST /auth/login โ { username, password }Server issues access token (+ refresh token)
On success, generate a short-lived access token (15 minutes to 1 hour is common) signed with your secret or private key. Optionally generate a longer-lived refresh token for getting new access tokens without re-authentication.
{ access_token: "eyJ...", refresh_token: "...", expires_in: 900 }Client stores tokens
The access token is typically held in memory (JavaScript variable). The refresh token, if used, goes in an HttpOnly cookie or secure storage. More on this in the storage section.
Client sends access token with each request
Every API request includes the access token in the Authorization header. This is the Bearer token pattern.
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Server validates the token
The server verifies: signature integrity, algorithm matches expected, token is not expired, audience matches this service, and any custom claims are valid. If all checks pass, the request is processed.
verify(token, secret, { algorithms: ['HS256'], audience: 'api.myapp.com' })Access token expires โ refresh flow triggers
When the access token expires, the client uses the refresh token to request a new access token from the auth server โ without requiring the user to log in again.
POST /auth/refresh โ { refresh_token: "..." }User logs out
The client discards the tokens. Because JWTs are stateless, the server-side impact depends on your architecture โ see the revocation discussion in the refresh token section.
Server-Side Validation: Every Check That Matters
Receiving a JWT is not the same as trusting it. Validation is not one step โ it's a sequence of checks, each of which can independently fail. Skip one and you may have a security hole.
Use your JWT library's verify function โ not decode. Verify rejects tampered tokens. Decode does not. This is the most common JWT mistake: calling the wrong function.
// โ Wrong โ doesn't verify signature const payload = jwt.decode(token); // โ Correct โ throws if signature invalid const payload = jwt.verify(token, secret);
Explicitly specify which algorithms are acceptable. If your server issues HS256 tokens, reject RS256 tokens โ and always reject "none". Many libraries accept whatever the token header says by default.
jwt.verify(token, secret, { algorithms: ['HS256'] });Most libraries do this automatically when you use verify(). But double-check. Allow a small clock skew tolerance (30โ60 seconds) to handle slight time differences between servers.
jwt.verify(token, secret, { clockTolerance: 30 });If you include an audience in your tokens, verify it on every request. This prevents a valid token issued for Service A from being replayed against Service B.
jwt.verify(token, secret, { audience: 'api.myapp.com' });Particularly important in multi-issuer environments or when consuming third-party tokens (e.g., Google, Auth0). Reject tokens from unexpected issuers.
jwt.verify(token, secret, { issuer: 'https://auth.myapp.com' });If you maintain a token blocklist (for logout or compromise scenarios), check it here. This adds latency since it requires a DB or cache lookup โ which is why most systems use short-lived access tokens to minimize this need.
const isRevoked = await tokenBlocklist.has(payload.jti);
if (isRevoked) throw new Error('Token revoked');Refresh Tokens and Rotation
Access tokens are intentionally short-lived โ typically 15 minutes to one hour. This limits the damage if a token is stolen: it expires quickly. But you don't want to force users to log in every 15 minutes. That's where refresh tokens come in.
A refresh token is a longer-lived credential (days to weeks) that can be exchanged for a new access token without requiring the user's password again. It's stored more securely than the access token and is only sent to the auth server โ never to regular API endpoints.
Refresh token rotation
The security-conscious pattern is refresh token rotation: every time a refresh token is used, it's immediately invalidated and a new one is issued. This enables detection of token theft: if an attacker and the legitimate user both try to use copies of the same refresh token, the second use will fail โ and you can invalidate the entire token family, logging the user out everywhere.
// Refresh token rotation flow
1. Client sends
POST /auth/refresh โ { refresh_token: "RT_abc123" }
2. Server verifies RT_abc123 and invalidates it
db.revokeRefreshToken("RT_abc123")
3. Server issues new token pair
{ access_token: "AT_new...", refresh_token: "RT_xyz789" }
4. If RT_abc123 used again โ token family revoked
// Possible theft detected โ revoke ALL tokens for this user
๐ก Short access token + rotation = practical security
Token revocation strategies
Short expiry
SimpleAccept that tokens live until they expire. Fast, zero infrastructure, appropriate when access tokens are very short-lived (< 5 min).
Token blocklist
BalancedMaintain a set of revoked token JTI (JWT ID) claims in Redis or a fast cache. Check on every request. Adds ~1ms of latency.
Token versioning
FlexibleStore a token version per user in your DB. Include it in the JWT. Reject any token with a version lower than the current one.
Where to Store JWTs
Token storage is one of the most debated topics in JWT security โ and the right answer depends on your threat model. Here are the three common options and their trade-offs.
Memory (JS variable)
BestXSS risk
โ Protected
CSRF risk
โ Protected
Persistence
โ Lost on refresh
Store the access token in a JavaScript variable (React state, a module-level variable, etc.). Never survives a page refresh, so pair with a refresh token in an HttpOnly cookie. The most XSS-resistant option.
HttpOnly Cookie
GoodXSS risk
โ Protected
CSRF risk
โ Vulnerable (use CSRF token)
Persistence
โ Survives refresh
Cookies with the HttpOnly flag cannot be read by JavaScript, which protects against XSS theft. But cookies are automatically sent with requests, so you must add CSRF protection (SameSite=Strict or a CSRF token). Best option for refresh tokens.
localStorage / sessionStorage
AvoidXSS risk
โ Vulnerable
CSRF risk
โ Protected
Persistence
โ Survives refresh
Accessible by any JavaScript on the page. A single XSS vulnerability in any dependency exposes every token stored here. Despite being widely used, this is the least secure storage option for sensitive tokens.
โ Recommended pattern for browser apps
HttpOnly; Secure; SameSite=Strict cookie. On page load, use the refresh token cookie to silently obtain a new access token. This combines XSS resistance for the sensitive short-lived token with CSRF resistance for the cookie.Security Mistakes to Avoid
โ Using jwt.decode() instead of jwt.verify()
Criticaldecode() simply reads the payload without checking the signature. Any attacker can craft a payload claiming to be an admin and it will be accepted. Always use verify().
โ Storing sensitive data in the payload
HighThe JWT payload is Base64 encoded โ anyone with the token can read it instantly using jwt.io or any Base64 decoder. Never store passwords, API keys, PII, or anything that shouldn't be publicly readable.
โ Setting expiry too long (or not at all)
HighTokens without an exp claim never expire. A token valid for 30 days gives an attacker 30 days to exploit a stolen one. Keep access tokens under 1 hour and always include exp.
โ Not validating the audience claim
MediumA valid JWT from your auth server can be replayed against any microservice that accepts your tokens if they don't validate aud. Service A's token should not be accepted by Service B.
โ Using weak HMAC secrets
HighIf you're using HS256, the secret is everything. Short or guessable secrets can be brute-forced offline against any JWT you've exposed. Use at least 256 bits of cryptographic randomness: crypto.randomBytes(32).
โ Trusting the kid (Key ID) header blindly
CriticalThe "kid" header claim tells the server which key to use for verification. If your server fetches the key from a URL specified in the token header without validation, an attacker can point it to their own key server โ signing their own tokens.
Implementation Patterns
A few concrete patterns that handle the most common scenarios correctly.
Issuing a token pair on login (Node.js)
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
async function login(userId, userRole) {
// Short-lived access token
const accessToken = jwt.sign(
{ sub: userId, role: userRole },
process.env.JWT_SECRET,
{
algorithm: 'HS256',
expiresIn: '15m',
audience: 'api.myapp.com',
issuer: 'https://auth.myapp.com',
jwtid: crypto.randomUUID(),
}
);
// Longer-lived refresh token (opaque, stored in DB)
const refreshToken = crypto.randomBytes(32).toString('hex');
await db.storeRefreshToken(userId, refreshToken, '7d');
return { accessToken, refreshToken };
}Secure verification middleware
function authMiddleware(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
const token = header.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // explicit allowlist
audience: 'api.myapp.com', // validate audience
issuer: 'https://auth.myapp.com',
clockTolerance: 30, // 30s clock skew tolerance
});
req.user = payload;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}Refresh token rotation
async function refreshTokens(oldRefreshToken) {
const stored = await db.getRefreshToken(oldRefreshToken);
if (!stored || stored.usedAt) {
// Possible replay โ revoke entire token family
if (stored?.userId) {
await db.revokeAllUserTokens(stored.userId);
}
throw new Error('Refresh token invalid or already used');
}
// Mark old token as used immediately
await db.markRefreshTokenUsed(oldRefreshToken);
// Issue new token pair
return login(stored.userId, stored.userRole);
}JWT Security Checklist
Run through this before shipping any JWT-based authentication flow.
Token creation
- Always include exp โ never issue non-expiring access tokens
- Include aud and iss claims
- Add jti (JWT ID) for revocation support
- Use 256+ bit random secret for HMAC or 2048+ bit RSA key
- Access tokens expire in 15โ60 minutes max
Token validation
- Use verify(), never decode()
- Explicitly allowlist accepted algorithms
- Validate aud and iss claims
- Reject tokens with alg: "none"
- Handle clock skew with tolerance (โค60s)
Storage & transport
- Access token in memory โ never localStorage
- Refresh token in HttpOnly Secure cookie
- Set SameSite=Strict on cookie
- Always use HTTPS โ never HTTP
- Never log full JWT values
Common pitfalls
- No secrets or PII in payload
- Not trusting kid header without validation
- Refresh token rotation enabled
- Token revocation strategy defined
- Algorithm confusion attacks tested
The bottom line
JWTs are not magic.
Understand what they guarantee.
A JWT proves a token hasn't been tampered with โ nothing more. It doesn't prevent XSS, doesn't handle revocation automatically, and doesn't protect a weak secret. Use them correctly and they're a powerful tool. Use them blindly and they're a liability.