JWT Authentication: Complete Guide to Token-Based Security
Everything you need to know about JSON Web Tokens — from the cryptographic foundations to production-hardened implementation patterns. Built from five years of shipping auth systems that actually hold up under pressure.
Table of Contents
- Introduction: Why JWT Won
- What Is a JWT?
- How JWT Authentication Works
- JWT Structure Deep Dive
- Implementing JWT Auth in Node.js
- Access Tokens vs Refresh Tokens
- JWT Storage: Where to Keep Tokens
- Common JWT Security Vulnerabilities
- JWT vs Sessions vs OAuth 2.0
- JWT in Microservices Architecture
- Production Checklist
- Conclusion
Introduction: Why JWT Won
If you have built any web application in the last decade, you have almost certainly encountered JSON Web Tokens. JWTs have become the de facto standard for authentication in modern web applications, APIs, and microservices. That did not happen by accident. The shift from server-side sessions to token-based authentication was driven by real architectural pressures that every growing engineering team eventually faces.
I remember the exact moment JWTs clicked for me. I was working on a platform that had grown from a single monolith to three backend services behind a load balancer. Our session-based auth system was falling apart. We had sticky sessions configured on the load balancer, a shared Redis instance that became a single point of failure, and a growing nightmare of session synchronization bugs. Every new service we added made the problem worse.
Token-based authentication solved this elegantly. Instead of the server keeping track of who is logged in, the client carries a cryptographically signed token that proves their identity. Any service can verify that token independently, without calling a central session store, without shared state, without coordination. The token itself is the session.
That said, JWTs are frequently misunderstood and misused. I have reviewed codebases where tokens never expire, where secrets are hardcoded in source control, where the algorithm is set to none in production, and where tokens store entire user profiles including email addresses and phone numbers. These are not edge cases. They are common mistakes that lead to real security incidents.
This guide covers everything you need to implement JWT authentication correctly. We will start from the cryptographic foundations defined in RFC 7519, build a complete Node.js implementation, address the real security threats you will face, and finish with a production checklist that I use on every project. Whether you are building your first auth system or hardening an existing one, this guide will give you the knowledge to do it right.
What Is a JWT?
A JSON Web Token (JWT, pronounced "jot") is an open standard defined in RFC 7519 that provides a compact, URL-safe way to represent claims between two parties. In practical terms, a JWT is a string that contains a JSON payload, is digitally signed so it cannot be tampered with, and can be verified by anyone who has the appropriate key.
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
That is three Base64URL-encoded strings separated by dots. Each part serves a distinct purpose:
- Header — Metadata about the token: which signing algorithm was used and the token type
- Payload — The actual claims (data) the token carries, such as user ID, roles, and expiration time
- Signature — A cryptographic signature that proves the header and payload have not been modified
The critical thing to understand is that a JWT is signed, not encrypted. Anyone can decode the header and payload — they are just Base64URL-encoded JSON. The signature only guarantees integrity: that the data has not been altered since it was signed. If you need to hide the contents of a token, you need JWE (JSON Web Encryption), which is a different specification entirely.
The relationship between the three parts is straightforward. The signature is computed over the encoded header and encoded payload using a secret key (for symmetric algorithms) or a private key (for asymmetric algorithms). When a server receives a token, it recomputes the signature using the same algorithm and key. If the recomputed signature matches, the token is authentic. If even a single character of the header or payload was changed, the signature check fails, and the token is rejected.
How JWT Authentication Works
The JWT authentication flow is conceptually simple, but there are important details at each step that determine whether your implementation is secure or vulnerable. Here is the complete flow:
Step 1: User Authentication
The user submits their credentials (username and password, OAuth token, or any other authentication factor) to your login endpoint. The server validates these credentials against your user store. This step has nothing to do with JWT — it is standard authentication. You are verifying that the user is who they claim to be.
Step 2: Token Issuance
Once the user is authenticated, the server creates a JWT containing claims about the user (their ID, roles, permissions) and signs it with a secret key. The server then returns this token to the client, typically in the response body for API-based auth, or as an HttpOnly cookie for browser-based applications.
// Server-side token issuance
const token = jwt.sign(
{
sub: user.id, // Subject: who this token is about
role: user.role, // Custom claim: user's role
iat: Math.floor(Date.now() / 1000), // Issued at
exp: Math.floor(Date.now() / 1000) + (15 * 60) // Expires in 15 min
},
process.env.JWT_SECRET,
{ algorithm: 'HS256' }
);
Step 3: Client Storage
The client stores the token (we will discuss where in detail later) and includes it in subsequent requests. For API requests, this is typically done via the Authorization header using the Bearer scheme:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Step 4: Token Verification
On every protected request, the server extracts the token from the request, verifies its signature, checks that it has not expired, and validates the claims. If everything checks out, the request proceeds with the user's identity attached to the request context. If verification fails, the server responds with a 401 Unauthorized status.
Step 5: Token Refresh
When the access token expires, the client uses a refresh token (a separate, longer-lived token) to request a new access token without forcing the user to log in again. This is the step most implementations get wrong, and we will cover it extensively later.
The beauty of this flow is that after step 2, the server never needs to look up session data in a database. The token itself contains everything needed to authorize the request. This is what makes JWTs so well-suited for distributed systems — any server with the verification key can validate the token independently.
JWT Structure Deep Dive
The Header
The JWT header is a JSON object that describes how the token is signed. It typically contains two fields:
{
"alg": "HS256",
"typ": "JWT"
}
The alg field specifies the signing algorithm. The most common algorithms are:
| Algorithm | Type | Key | Best For |
|---|---|---|---|
HS256 |
Symmetric (HMAC) | Shared secret | Single-server apps, internal services |
RS256 |
Asymmetric (RSA) | Private/public key pair | Microservices, third-party verification |
ES256 |
Asymmetric (ECDSA) | Private/public key pair | Performance-sensitive systems, mobile |
PS256 |
Asymmetric (RSA-PSS) | Private/public key pair | High-security environments |
HS256 uses a single shared secret for both signing and verification. It is fast and simple, but every service that needs to verify tokens must have the secret, which means any of them could also forge tokens. RS256 uses an RSA key pair: the private key signs tokens, and the public key verifies them. Services only need the public key to verify, and they cannot forge tokens. ES256 uses elliptic curve cryptography, which provides equivalent security to RS256 with much smaller keys and faster signature operations.
For most microservice architectures, I recommend RS256 or ES256. The asymmetric model means you can distribute the public key freely without compromising security. Only the auth service needs the private key.
The Payload (Claims)
The payload contains claims — statements about the user and metadata about the token. The JWT specification defines seven registered claims:
iss(Issuer) — Who issued the token. Example:"iss": "https://auth.myapp.com"sub(Subject) — Who the token is about, typically the user ID. Example:"sub": "user_8f3k2j"aud(Audience) — Who the token is intended for. Example:"aud": "https://api.myapp.com"exp(Expiration Time) — When the token expires, as a Unix timestamp. Example:"exp": 1714000000nbf(Not Before) — The token is not valid before this time. Useful for pre-issued tokens.iat(Issued At) — When the token was created. Example:"iat": 1713999100jti(JWT ID) — A unique identifier for the token. Essential for token revocation and replay prevention.
Beyond the registered claims, you can add custom claims for your application's needs:
{
"sub": "user_8f3k2j",
"iss": "https://auth.myapp.com",
"aud": "https://api.myapp.com",
"exp": 1714000000,
"iat": 1713999100,
"jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"role": "admin",
"permissions": ["read:users", "write:users", "delete:users"],
"org_id": "org_xyz789"
}
sub claim as a lookup key.
The Signature
The signature is computed by applying the algorithm specified in the header to the encoded header and payload. For HS256, the signature is computed as:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
For RS256, the same input is signed with the RSA private key using SHA-256. The resulting signature is then Base64URL-encoded and appended as the third part of the token. This signature is what makes JWTs tamper-proof. If an attacker modifies even a single character in the payload (for example, changing "role": "user" to "role": "admin"), the signature verification will fail because the signature was computed over the original, unmodified data.
Implementing JWT Auth in Node.js
Let us build a complete, production-quality JWT authentication system in Node.js. We will use the jsonwebtoken library, which is the most widely used JWT implementation for Node.js with over 17 million weekly downloads.
Project Setup
npm install jsonwebtoken bcryptjs express cookie-parser
npm install -D @types/jsonwebtoken
Configuration
// config/auth.js
module.exports = {
accessToken: {
secret: process.env.JWT_ACCESS_SECRET, // Min 256 bits for HS256
expiresIn: '15m', // Short-lived
},
refreshToken: {
secret: process.env.JWT_REFRESH_SECRET, // Different secret!
expiresIn: '7d', // Long-lived
},
bcryptRounds: 12,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/api/auth/refresh', // Only sent to refresh endpoint
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms
}
};
Token Service
// services/tokenService.js
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const config = require('../config/auth');
class TokenService {
generateAccessToken(user) {
return jwt.sign(
{
sub: user.id,
role: user.role,
permissions: user.permissions,
jti: crypto.randomUUID(),
},
config.accessToken.secret,
{
algorithm: 'HS256',
expiresIn: config.accessToken.expiresIn,
issuer: 'myapp-auth',
audience: 'myapp-api',
}
);
}
generateRefreshToken(user) {
const jti = crypto.randomUUID();
const token = jwt.sign(
{
sub: user.id,
jti: jti,
type: 'refresh',
},
config.refreshToken.secret,
{
algorithm: 'HS256',
expiresIn: config.refreshToken.expiresIn,
issuer: 'myapp-auth',
}
);
return { token, jti };
}
verifyAccessToken(token) {
return jwt.verify(token, config.accessToken.secret, {
algorithms: ['HS256'], // IMPORTANT: Always specify allowed algorithms
issuer: 'myapp-auth',
audience: 'myapp-api',
});
}
verifyRefreshToken(token) {
return jwt.verify(token, config.refreshToken.secret, {
algorithms: ['HS256'],
issuer: 'myapp-auth',
});
}
}
module.exports = new TokenService();
Authentication Middleware
// middleware/authenticate.js
const tokenService = require('../services/tokenService');
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: 'authentication_required',
message: 'Missing or malformed Authorization header',
});
}
const token = authHeader.slice(7); // Remove 'Bearer ' prefix
try {
const payload = tokenService.verifyAccessToken(token);
req.user = {
id: payload.sub,
role: payload.role,
permissions: payload.permissions,
};
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'token_expired',
message: 'Access token has expired',
});
}
return res.status(401).json({
error: 'invalid_token',
message: 'Token verification failed',
});
}
}
// Role-based authorization middleware
function authorize(...allowedRoles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'authentication_required' });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
error: 'insufficient_permissions',
message: `Required role: ${allowedRoles.join(' or ')}`,
});
}
next();
};
}
module.exports = { authenticate, authorize };
Auth Routes
// routes/auth.js
const express = require('express');
const bcrypt = require('bcryptjs');
const tokenService = require('../services/tokenService');
const config = require('../config/auth');
const User = require('../models/User');
const RefreshToken = require('../models/RefreshToken');
const router = express.Router();
// POST /api/auth/login
router.post('/login', async (req, res) => {
const { email, password } = req.body;
// 1. Find user
const user = await User.findByEmail(email);
if (!user) {
// Use same error message to prevent user enumeration
return res.status(401).json({ error: 'Invalid email or password' });
}
// 2. Verify password
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return res.status(401).json({ error: 'Invalid email or password' });
}
// 3. Generate tokens
const accessToken = tokenService.generateAccessToken(user);
const { token: refreshToken, jti } = tokenService.generateRefreshToken(user);
// 4. Store refresh token in database (for revocation)
await RefreshToken.create({
jti: jti,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
// 5. Set refresh token as HttpOnly cookie
res.cookie('refreshToken', refreshToken, config.cookie);
// 6. Return access token in response body
res.json({
accessToken,
expiresIn: 900, // 15 minutes in seconds
tokenType: 'Bearer',
});
});
// POST /api/auth/refresh
router.post('/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token provided' });
}
try {
// 1. Verify the refresh token signature and claims
const payload = tokenService.verifyRefreshToken(refreshToken);
// 2. Check if the refresh token exists in database (not revoked)
const storedToken = await RefreshToken.findByJti(payload.jti);
if (!storedToken) {
// Token has been revoked - possible token theft!
// Revoke ALL refresh tokens for this user
await RefreshToken.revokeAllForUser(payload.sub);
res.clearCookie('refreshToken');
return res.status(401).json({
error: 'token_revoked',
message: 'Refresh token has been revoked. Please log in again.',
});
}
// 3. Token rotation: delete old token, issue new pair
await RefreshToken.deleteByJti(payload.jti);
const user = await User.findById(payload.sub);
const newAccessToken = tokenService.generateAccessToken(user);
const { token: newRefreshToken, jti } = tokenService.generateRefreshToken(user);
await RefreshToken.create({
jti: jti,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
res.cookie('refreshToken', newRefreshToken, config.cookie);
res.json({
accessToken: newAccessToken,
expiresIn: 900,
tokenType: 'Bearer',
});
} catch (err) {
res.clearCookie('refreshToken');
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
// POST /api/auth/logout
router.post('/logout', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
try {
const payload = tokenService.verifyRefreshToken(refreshToken);
await RefreshToken.deleteByJti(payload.jti);
} catch (e) {
// Token invalid, but we still clear the cookie
}
}
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
});
module.exports = router;
Using Protected Routes
// app.js
const express = require('express');
const cookieParser = require('cookie-parser');
const authRoutes = require('./routes/auth');
const { authenticate, authorize } = require('./middleware/authenticate');
const app = express();
app.use(express.json());
app.use(cookieParser());
// Public routes
app.use('/api/auth', authRoutes);
// Protected routes - any authenticated user
app.get('/api/profile', authenticate, (req, res) => {
res.json({ userId: req.user.id, role: req.user.role });
});
// Protected routes - admin only
app.delete('/api/users/:id',
authenticate,
authorize('admin'),
async (req, res) => {
// Only admins can reach here
await User.delete(req.params.id);
res.json({ message: 'User deleted' });
}
);
app.listen(3000);
Want to decode and inspect JWT tokens from your application? Try our free browser-based tool.
Open JWT Decoder ToolAccess Tokens vs Refresh Tokens
One of the most important patterns in JWT authentication is the separation of access tokens and refresh tokens. This is not just a convention — it is a security architecture that addresses the fundamental tension between usability and security.
The Core Problem
JWTs are stateless by design. Once a token is issued, the server cannot revoke it without maintaining some state (which defeats the purpose). If you issue a token that is valid for 30 days, and that token is stolen, the attacker has 30 days of access. If you issue a token that expires every 5 minutes, the user has to log in constantly. Neither extreme is acceptable.
The Two-Token Solution
The solution is to use two tokens with different lifespans and different security properties:
| Property | Access Token | Refresh Token |
|---|---|---|
| Lifespan | 5-15 minutes | 7-30 days |
| Purpose | Authorize API requests | Obtain new access tokens |
| Sent with | Every API request | Only to the refresh endpoint |
| Storage | Memory (JavaScript variable) | HttpOnly cookie |
| Revocable | No (expires naturally) | Yes (tracked in database) |
| Contains | User ID, role, permissions | User ID, token ID only |
The access token is short-lived and stateless. Even if it is stolen, the attacker has a narrow window of exploitation. The refresh token is long-lived but tracked server-side, so it can be revoked immediately if compromised.
Refresh Token Rotation
Refresh token rotation is a critical security pattern. Every time a refresh token is used, the server issues a new refresh token and invalidates the old one. This creates a chain of single-use tokens. If an attacker steals a refresh token and uses it, the legitimate user's next refresh attempt will fail (because the token was already consumed), alerting you to the compromise. At that point, you revoke all refresh tokens for the user and force re-authentication.
Here is the logic in pseudocode:
// When a refresh token is presented:
1. Verify the token signature
2. Look up the token's jti in the database
3. If NOT found:
- Token was already used (replay attack!) or revoked
- Revoke ALL refresh tokens for this user (nuclear option)
- Return 401
4. If found:
- Delete the old token from the database
- Issue a new access token + new refresh token
- Store the new refresh token's jti in the database
- Return the new token pair
This pattern is recommended by the OAuth 2.0 Security Best Current Practice (RFC 9700) and is used by Auth0, Okta, and other major identity providers. Implementing it correctly is the single most impactful thing you can do for your JWT security.
JWT Storage: Where to Keep Tokens
Token storage is one of the most debated topics in front-end security, and for good reason. Where you store your tokens determines which attacks you are vulnerable to. There is no perfect answer — only tradeoffs. Here is an honest assessment of each option.
Option 1: localStorage
// Storing in localStorage
localStorage.setItem('accessToken', token);
// Reading from localStorage
const token = localStorage.getItem('accessToken');
fetch('/api/data', {
headers: { Authorization: `Bearer ${token}` }
});
Pros: Simple to implement. Persists across tabs and page refreshes. Works well with any API.
Cons: Vulnerable to XSS (Cross-Site Scripting). If an attacker can execute JavaScript on your page (through a malicious npm package, a compromised CDN, or an injection vulnerability), they can read the token directly with localStorage.getItem('accessToken') and exfiltrate it to their server. Game over.
Option 2: HttpOnly Cookies
// Server sets the cookie
res.cookie('accessToken', token, {
httpOnly: true, // JavaScript cannot access this cookie
secure: true, // Only sent over HTTPS
sameSite: 'strict', // Not sent with cross-origin requests
maxAge: 900000, // 15 minutes
path: '/',
});
Pros: Not accessible to JavaScript, so XSS attacks cannot steal the token. The browser handles sending the cookie automatically.
Cons: Vulnerable to CSRF (Cross-Site Request Forgery) unless you also use SameSite=Strict or implement CSRF tokens. Adds complexity with cross-domain setups. Subject to cookie size limits (4KB).
Option 3: In-Memory (JavaScript Variable)
// Token stored in a closure or module variable
let accessToken = null;
export function setToken(token) {
accessToken = token;
}
export function getToken() {
return accessToken;
}
// Used in fetch interceptor
export async function authFetch(url, options = {}) {
if (!accessToken) {
await refreshAccessToken(); // Get new token using refresh cookie
}
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
});
}
Pros: Most secure against both XSS (token is not in any persistent storage an attacker can query) and CSRF (token is sent as a header, not a cookie). Token is cleared automatically when the tab is closed.
Cons: Token is lost on page refresh. Does not persist across tabs. Requires a silent refresh mechanism to obtain new access tokens.
The Recommended Pattern
After shipping multiple production auth systems, the pattern I consistently use is:
- Access token: in memory (JavaScript variable, not localStorage)
- Refresh token: HttpOnly, Secure, SameSite=Strict cookie
When the page loads, the client silently calls the refresh endpoint. The browser automatically sends the refresh cookie, and the server responds with a new access token that is stored in memory. This gives you the best of both worlds: the access token is not in any persistent storage that XSS can reach, and the refresh token is not accessible to JavaScript at all.
HttpOnly prevents JavaScript access. Secure ensures the cookie is only sent over HTTPS. SameSite=Strict prevents the cookie from being sent with cross-origin requests, mitigating CSRF. These three attributes together provide robust protection.
Common JWT Security Vulnerabilities
JWTs have a well-documented history of security vulnerabilities, not because the standard is flawed, but because implementations frequently take shortcuts. Here are the attacks you need to know about and how to defend against them.
1. The alg: none Attack
The JWT specification allows an algorithm of none, which means no signature at all. This was intended for situations where the token integrity is guaranteed by other means (like TLS), but it created a devastating vulnerability. An attacker can take a legitimate token, change the algorithm to none, remove the signature, modify the payload (for example, changing "role": "user" to "role": "admin"), and if the server does not explicitly reject the none algorithm, the token is accepted.
// VULNERABLE - accepts whatever algorithm the token claims
const payload = jwt.verify(token, secret);
// SECURE - explicitly specify allowed algorithms
const payload = jwt.verify(token, secret, {
algorithms: ['HS256'] // Only accept HS256
});
Defense: Always specify the algorithms option when verifying tokens. Never leave it as the default. The jsonwebtoken library for Node.js has protected against this attack since version 9.0 by requiring the algorithms option when using jwt.verify() with a string secret, but you should always be explicit.
2. Key Confusion Attack (Algorithm Switching)
This attack targets systems that use asymmetric algorithms (RS256). The server has a public key for verification. An attacker takes the public key (which is, by definition, public), creates a new token signed with HS256 using the public key as the HMAC secret. If the server does not check the algorithm and naively uses its "key" for verification, it will use the public key as an HMAC secret and the forged token will validate.
// VULNERABLE - uses the same key regardless of algorithm
const payload = jwt.verify(token, publicKey);
// SECURE - enforce the expected algorithm
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'] // Only accept RS256, reject HS256
});
Defense: Same as above: always specify the allowed algorithms. If you use RS256, only allow RS256. If you use HS256, only allow HS256. Never allow the token to dictate which algorithm is used.
3. Token Stuffing and Bloated Tokens
Some developers store too much data in the JWT payload: entire user profiles, complete permission matrices, nested objects. This creates several problems. First, the token is sent with every request, so a 4KB token means 4KB of overhead on every API call. Second, large tokens may exceed cookie size limits or header size limits. Third, stale data in the token (like a cached role that has since been revoked) becomes a security issue.
Defense: Keep tokens small. Include only the minimum claims needed for authorization. Use the sub claim to look up additional data when needed. A good access token payload is typically under 500 bytes.
4. Missing Expiration
Tokens without an exp claim never expire. If such a token is stolen, the attacker has permanent access. I have seen this in production more times than I care to admit, usually because the developer set up JWT during initial development and forgot to add expiration.
// VULNERABLE - no expiration
const token = jwt.sign({ sub: user.id }, secret);
// SECURE - always set expiration
const token = jwt.sign({ sub: user.id }, secret, { expiresIn: '15m' });
Defense: Always set exp. Verify that exp is present during verification. Set access token expiration to 15 minutes or less.
5. Insufficient Secret Key Strength
Using weak secrets like "secret", "password123", or "myapp-jwt-key" means an attacker can brute-force the signing key and forge arbitrary tokens. Tools like jwt-cracker and hashcat can crack weak HMAC secrets in seconds.
// VULNERABLE
const secret = 'my-jwt-secret';
// SECURE - generate a cryptographically strong secret
// Run: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
const secret = process.env.JWT_SECRET; // 512-bit random hex string
Defense: Use at least 256 bits of entropy for HS256 secrets. Generate secrets with a cryptographic random number generator. Never hardcode secrets in source code. Use environment variables or a secrets manager.
6. Timing Attacks on Signature Verification
If signature comparison is done with a simple string equality check (===), it may be vulnerable to timing attacks where an attacker can determine the correct signature byte by byte based on how long the comparison takes. Production JWT libraries use constant-time comparison functions, but custom implementations sometimes skip this.
Defense: Use established JWT libraries (jsonwebtoken, jose) that implement constant-time comparison. Never write your own signature verification logic.
JWT vs Sessions vs OAuth 2.0
JWTs are not the right solution for every authentication scenario. Understanding when to use JWTs versus server-side sessions versus OAuth 2.0 is essential for making good architectural decisions.
| Criteria | Server-Side Sessions | JWT | OAuth 2.0 |
|---|---|---|---|
| State | Stateful (server stores sessions) | Stateless (token carries state) | Depends on grant type |
| Scalability | Requires shared session store | Scales horizontally easily | Scales well with JWTs |
| Revocation | Instant (delete from store) | Difficult (requires blocklist) | Supported via token introspection |
| Cross-domain | Difficult (cookie limitations) | Easy (Authorization header) | Designed for it |
| Mobile/SPA | Challenging | Natural fit | Natural fit (PKCE flow) |
| Complexity | Low | Medium | High |
| Best for | Traditional server-rendered apps | APIs, microservices, SPAs | Third-party access, SSO |
When to Use Server-Side Sessions
If you are building a traditional server-rendered application (Rails, Django, Laravel, Next.js with server components) that runs on a single server or a small cluster, server-side sessions are simpler and offer immediate revocation. You do not need JWTs for everything. A session cookie with a server-side store is battle-tested, well-understood, and arguably more secure because you have full control over session lifetime.
When to Use JWT
JWTs excel when you need stateless authentication across multiple services, when you are building APIs consumed by mobile apps or SPAs, or when you need to pass user identity between microservices without a central session store. If your architecture is distributed, JWTs are the practical choice.
When to Use OAuth 2.0
OAuth 2.0 is an authorization framework, not an authentication protocol (though OpenID Connect adds authentication on top). Use OAuth when you need to grant third-party applications limited access to your users' resources (the classic "Sign in with Google" pattern), when you need federated identity across multiple organizations, or when you need a standardized protocol for delegated authorization. Note that OAuth 2.0 frequently uses JWTs as the token format, so these are not mutually exclusive.
JWT in Microservices Architecture
JWT authentication truly shines in microservices architectures. When you have dozens of services that need to verify user identity, the alternative — having every service call a central auth service for every request — creates a bottleneck that defeats the purpose of decomposing your system. JWTs solve this by distributing verification capability to every service.
Token Propagation Pattern
In a microservices architecture, when Service A receives a request with a JWT and needs to call Service B, it should propagate the same JWT:
// Service A calling Service B
async function getUserOrders(req) {
// Forward the same JWT to downstream services
const response = await fetch('http://order-service/api/orders', {
headers: {
Authorization: req.headers.authorization, // Propagate the token
},
});
return response.json();
}
This pattern works because every service can independently verify the JWT using the public key. No service-to-service authentication tokens are needed for requests that are on behalf of a user.
Public Key Distribution
For microservices, you should use asymmetric algorithms (RS256 or ES256). The auth service holds the private key and signs tokens. All other services have the public key and can only verify tokens. There are three common patterns for distributing public keys:
1. JWKS (JSON Web Key Set) Endpoint: The auth service exposes a /.well-known/jwks.json endpoint that returns the current public keys. Other services fetch and cache these keys. This is the standard approach used by Auth0, Okta, and AWS Cognito.
// Verifying with JWKS endpoint using 'jwks-rsa' library
const jwksClient = require('jwks-rsa');
const jwt = require('jsonwebtoken');
const client = jwksClient({
jwksUri: 'https://auth.myapp.com/.well-known/jwks.json',
cache: true,
cacheMaxAge: 600000, // Cache for 10 minutes
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
const signingKey = key.getPublicKey();
callback(null, signingKey);
});
}
function verifyToken(token) {
return new Promise((resolve, reject) => {
jwt.verify(token, getKey, {
algorithms: ['RS256'],
issuer: 'https://auth.myapp.com',
}, (err, decoded) => {
if (err) reject(err);
else resolve(decoded);
});
});
}
2. Configuration-Based: The public key is deployed as a configuration value (environment variable, mounted file, or config service). Simpler than JWKS but requires redeployment to rotate keys.
3. API Gateway Validation: The API gateway verifies the JWT once at the edge and passes the validated claims to downstream services as trusted headers. Downstream services trust the gateway and do not perform their own JWT verification.
Gateway Validation Pattern
In many production architectures, an API gateway (Kong, Envoy, AWS API Gateway, or a custom Node.js gateway) handles JWT verification at the edge. This has several advantages: centralized key management, consistent token validation, and reduced overhead in individual services. The gateway strips the JWT and injects trusted headers:
// API Gateway middleware
function gatewayAuth(req, res, next) {
const token = extractToken(req);
const payload = verifyAccessToken(token);
// Set trusted headers for downstream services
req.headers['x-user-id'] = payload.sub;
req.headers['x-user-role'] = payload.role;
req.headers['x-request-id'] = crypto.randomUUID();
// Remove the Authorization header to prevent token leakage
delete req.headers.authorization;
next();
}
// Downstream service trusts the gateway headers
app.get('/api/orders', (req, res) => {
const userId = req.headers['x-user-id']; // Trusted, set by gateway
// ... fetch orders for userId
});
x-user-id header directly and bypass authentication entirely.
Key Rotation
In production, you need to rotate signing keys periodically. The JWKS approach makes this straightforward. The auth service starts signing new tokens with a new key (identified by a new kid in the header), while keeping the old public key in the JWKS endpoint until all tokens signed with the old key have expired. This allows zero-downtime key rotation.
Production Checklist
Before deploying JWT authentication to production, run through this checklist. Each item addresses a specific failure mode I have encountered in real systems over the years.
Token Configuration
- Access token expiration is 15 minutes or less. Shorter is better. I use 10 minutes for high-security applications. This limits the damage window if a token is stolen.
- All tokens have an
expclaim. Verify this in your token generation code and in your verification logic. Reject tokens withoutexp. - Tokens include
issandaudclaims. Verify both during token validation. This prevents tokens issued by one service from being accepted by another. - Each token has a unique
jticlaim. This is essential for token revocation and audit logging. Usecrypto.randomUUID()to generate it. - Token payload is minimal. Include only the claims needed for authorization decisions. No emails, no names, no addresses in the token.
Key Management
- Signing secret is at least 256 bits of entropy. Generate with
crypto.randomBytes(64).toString('hex'). Never use human-readable phrases. - Secrets are stored in environment variables or a secrets manager. Never in source code, never in configuration files committed to version control.
- Different secrets for access tokens and refresh tokens. This prevents an access token from being used as a refresh token or vice versa.
- Key rotation plan is documented and tested. You should be able to rotate keys with zero downtime. Test this before you need it.
- For microservices, use asymmetric algorithms (RS256/ES256). Only the auth service should be able to sign tokens. All other services only need to verify.
Verification
- Allowed algorithms are explicitly specified. Always pass the
algorithmsoption tojwt.verify(). Never let the token dictate the algorithm. iss,aud, andexpare validated during verification. Do not just verify the signature. Validate the claims too.- Clock skew tolerance is configured. Set a small
clockTolerance(5-30 seconds) to account for minor time differences between servers. But not too large, as this extends the effective token lifetime.
Refresh Tokens
- Refresh token rotation is implemented. Every refresh issues a new refresh token and invalidates the old one. Detect reuse of old refresh tokens as a sign of compromise.
- Refresh tokens are stored in HttpOnly, Secure, SameSite cookies. They should never be accessible to JavaScript.
- Refresh tokens are tracked in a database. You need the ability to revoke individual tokens and all tokens for a user.
- Absolute lifetime limit on refresh token families. Even with rotation, require re-authentication after a maximum period (for example, 30 days). This limits the damage from a compromised refresh token chain.
Infrastructure
- All auth endpoints are rate-limited. The login, refresh, and registration endpoints should have aggressive rate limits to prevent brute-force attacks.
- HTTPS is enforced everywhere. Tokens transmitted over HTTP can be intercepted trivially. Use HSTS headers to prevent protocol downgrade attacks.
- CORS is configured correctly. Only allow your frontend domains. Do not use
Access-Control-Allow-Origin: *on authenticated endpoints. - Error messages do not leak information. Token verification errors should return generic messages. Do not tell the client why the token was rejected (expired, wrong algorithm, invalid signature) in production — log the details server-side.
- Token activity is logged. Log token issuance, refresh, and revocation events with timestamps, user IDs, and IP addresses. This is essential for incident response.
Client-Side
- Access tokens are stored in memory, not localStorage. Use a JavaScript closure or module-scoped variable. Accept the tradeoff of losing the token on page refresh.
- Silent refresh is implemented. On page load, automatically call the refresh endpoint to obtain a new access token. Set up a timer to refresh proactively before the access token expires.
- Token refresh failure triggers a clean logout. If the refresh endpoint returns 401, clear all stored tokens and redirect to login. Do not keep retrying indefinitely.
Conclusion
JWT authentication is powerful, flexible, and well-suited for modern web architectures. But it is not something you can bolt on carelessly. The difference between a secure JWT implementation and a vulnerable one comes down to the details: explicit algorithm verification, short token lifetimes, proper key management, refresh token rotation, and secure storage strategies.
After five years of building authentication systems, the lessons that stand out most are not about JWTs specifically, but about the engineering discipline around them. Rotate your secrets before you need to, not after an incident forces you to. Test your refresh flow under failure conditions. Log everything. Keep your tokens small and your expiration times short. And always, always specify the allowed algorithms.
The implementation patterns in this guide are not theoretical. They come from systems handling millions of authenticated requests, from debugging token verification failures at 3 AM, and from post-mortem analyses of security incidents. Use them as a foundation, adapt them to your specific requirements, and treat your authentication layer with the respect it deserves. It is the front door to everything your users have entrusted you with.
If you are implementing JWT authentication for the first time, start with the Node.js code in this guide and the production checklist. If you are auditing an existing implementation, work through the checklist item by item. Either way, take the time to understand the "why" behind each recommendation. Security patterns that you implement without understanding become patterns you will eventually misconfigure.
Need to quickly decode, verify, or debug a JWT token? Use our free browser-based JWT Decoder.
Open JWT Decoder Tool