How to Use JWT for Web Authentication: A Complete Guide
Master JWT for Web Authentication: Learn Access & Refresh Token flows, secure storage strategies against XSS/CSRF, and production middleware setup.
JSON Web Token (JWT) is one of the most widely adopted standards for authentication in modern web development. From Single Page Applications (React, Vue, Svelte) to Microservices and Mobile APIs, JWT is ubiquitous.
However, implementing JWT correctly and securely remains a significant challenge for many engineers: Where should tokens be stored? How should session expiration and token refresh be handled? How can we defend against both XSS and CSRF?
Let’s break down the complete architecture of Web Authentication with JWT from the ground up.
1. Explain Like I’m 10 (ELI5): Locker Tickets vs. VIP Wristbands#
To understand why JWT was invented, compare it to traditional session-based authentication using the analogy of a theme park:
Traditional Approach (Session-based): “Locker Keys and Ledger”#
- When you enter the park, the front desk gives you a numbered locker ticket (Session ID).
- All your personal details (Name, VIP status, entry timestamp) are written in a master ledger book behind the front desk (Database or Redis).
- Every time you want to ride a roller coaster, the operator must call the front desk: “Can you check the ledger to see if ticket #102 has VIP access?”.
- The Bottleneck: When 100,000 visitors ride 50 attractions simultaneously, the front desk phone lines crash under the weight of continuous lookups.
Modern Approach (JWT-based): “Digitally Signed VIP Wristbands”#
- When you enter the park, the front desk hands you a smart electronic wristband (JWT).
- Printed on the wristband itself is:
Name: Alice, Tier: VIP, Expires: 18:00. - The management applies a tamper-proof digital seal (Cryptographic Signature) to the wristband.
- Whenever you enter a ride, the operator simply verifies the digital seal on the spot and reads your
VIPstatus immediately — no central database lookup required.
flowchart TD
subgraph Traditional["1. Traditional Stateful Sessions"]
Client1["Client"] -->|Sends Session ID| Server1["App Server"]
Server1 -->|DB Lookup Every Request| DB["Database / Redis<br/>Session Store"]
end
subgraph JWTWay["2. Stateless JWT Authentication"]
Client2["Client"] -->|Sends JWT Token| Server2["App Server"]
Server2 -->|Validates Signature Locally| Server2
end
2. The Three Parts of a JSON Web Token#
A JWT is a compact, URL-safe string made of three Base64URL-encoded parts separated by dots (.):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTYiLCJuYW1lIjoiQWxpY2UiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MDAwMDAwMDB9.4a5b6c...text- Header: Defines the token type (
JWT) and cryptographic algorithm (e.g.,HS256orRS256). - Payload (Claims): Contains the identity claims being transmitted, such as
sub(User ID),role(Permissions),exp(Expiration timestamp), andiat(Issued-at timestamp). - Signature: Generated by taking the encoded Header and Payload and signing them with a private server secret or asymmetric private key.
[!WARNING] Payload is NOT encrypted! It is only Base64URL encoded. Anyone can decode and inspect its contents. Never place passwords, credit card numbers, or secret API keys in a JWT payload!
3. Production Architecture: The Dual-Token Pattern#
Using a single JWT with a long lifetime is dangerous if intercepted. Conversely, a very short token forces users to log in repeatedly.
The industry-standard solution is the Dual-Token Pattern:
- Access Token: Very short-lived (10 - 15 minutes), attached to incoming API requests for fast authorization.
- Refresh Token: Longer-lived (7 - 30 days), stored securely and used exclusively to request new Access Tokens without user disruption.
flowchart TD
User["Client Browser"]
AuthServer["Auth API Server"]
ResourceServer["Protected Resource Server"]
User -->|1. POST /login - Credentials| AuthServer
AuthServer -->|2. Returns Access Token and Refresh Cookie| User
User -->|3. API Request with Bearer Token| ResourceServer
ResourceServer -->|4. Authorized Response 200 OK| User
User -->|5. Token Expired 401 Unauthorized| ResourceServer
User -->|6. POST /refresh with HttpOnly Cookie| AuthServer
AuthServer -->|7. Issues Fresh Access Token - Rotation| User
4. Where Should You Store Tokens on the Client? (XSS vs. CSRF)#
Choosing where to persist tokens on the client is critical to security:
| Storage Location | XSS Risk | CSRF Risk | Evaluation |
|---|---|---|---|
localStorage / sessionStorage | ❌ Critical (Accessible by any malicious injected JS script) | ✅ Immune (Not automatically transmitted by browsers) | Not recommended for long-lived tokens |
Standard Cookie (No HttpOnly) | ❌ Critical (Readable via document.cookie) | ❌ High (Automatically attached across origins) | High risk |
HttpOnly + Secure + SameSite Cookie | ✅ Protected (JavaScript cannot read the cookie) | ✅ Protected (SameSite=Lax/Strict blocks cross-site triggers) | Recommended for Refresh Tokens |
| In-Memory Variable (JS State) | ✅ Protected (Isolated in memory, wiped on tab close) | ✅ Protected (Browser does not auto-attach) | Recommended for Access Tokens |
Best Practice Architecture#
- Access Token: Held strictly in In-Memory JavaScript State (e.g., React Context, Pinia, Svelte Store).
- Refresh Token: Persisted in an
HttpOnlyCookie withSecureandSameSite=Lax(orStrict). - When the user opens or refreshes a tab, the client performs a Silent Refresh (
POST /auth/refresh) to populate the in-memory Access Token.
5. Practical Implementation with Node.js & TypeScript#
5.1. Authentication Controller (Login & Issuing Tokens)#
import { Request, Response } from 'express';
import jwt from 'jsonwebtoken';
const ACCESS_SECRET = process.env.ACCESS_TOKEN_SECRET || 'access_secret_key';
const REFRESH_SECRET = process.env.REFRESH_TOKEN_SECRET || 'refresh_secret_key';
export async function login(req: Request, res: Response) {
const { email, password } = req.body;
// 1. Verify user credentials against the database
const user = await validateUserCredentials(email, password);
if (!user) return res.status(401).json({ message: 'Invalid credentials' });
// 2. Sign short-lived Access Token (15 minutes)
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
// 3. Sign long-lived Refresh Token (7 days)
const refreshToken = jwt.sign(
{ userId: user.id },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
// 4. Send Refresh Token in a secure HttpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
// 5. Send Access Token in response body
return res.json({ accessToken });
}typescript5.2. Protected Route Middleware#
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Missing or malformed authorization token' });
}
const token = authHeader.split(' ')[1];
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET!, (err, decodedUser) => {
if (err) {
return res.status(401).json({ message: 'Invalid or expired access token' });
}
(req as any).user = decodedUser;
next();
});
}typescript5.3. Refresh Token Endpoint (Token Rotation)#
export async function refreshAccessToken(req: Request, res: Response) {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token not found' });
}
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET!, (err: any, decoded: any) => {
if (err) return res.status(403).json({ message: 'Invalid refresh token' });
const newAccessToken = jwt.sign(
{ userId: decoded.userId, role: decoded.role },
process.env.ACCESS_TOKEN_SECRET!,
{ expiresIn: '15m' }
);
return res.json({ accessToken: newAccessToken });
});
}typescript6. Common JWT Security Pitfalls#
[!TIP] JWT Security Checklist:
- Strong Secrets: Use at least 256-bit cryptographically random secrets or asymmetric RSA (
RS256) / ECDSA (ES256) keypairs.- Reject
"alg": "none": Always configure your validator to explicitly whitelist trusted algorithms.- Validate Audience (
aud) and Issuer (iss): Ensure tokens were issued by your identity provider specifically for your API.- Implement Token Rotation: Invalidate old refresh tokens upon renewal to detect and mitigate replay attacks.
7. Summary#
Using JWT for Web Authentication provides unmatched scalability and flexibility for modern distributed systems. By adopting the Dual-Token Pattern, storing Access Tokens in Memory, and securing Refresh Tokens in HttpOnly SameSite Cookies, you achieve the optimal balance of user experience and enterprise-grade security.