Table of Contents
JSON Web Tokens (JWT) are the standard for stateless authentication in modern web applications. But implementing JWT correctly requires understanding security implications. Letβs build a secure authentication system from scratch.
How JWT Works
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β JWT Structure β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Header β Payload β Signature β
β βββββββββββββ β ββββββββββββββββ β βββββββββββββββ β
β { β { β HMACSHA256( β
β "alg":"HS256" β "sub": "123", β base64(header) + β
β "typ":"JWT" β "name": "John", β "." + β
β } β "iat": 123456, β base64(payload), β
β β "exp": 123556 β secret β
β β } β ) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β eyJhbGciOiJIUzI1N.eyJzdWIiOiIxMjM0NTY.SflKxwRJSMeKKF2QT4 β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Project Setup
npm install express jsonwebtoken bcrypt cookie-parser
npm install -D typescript @types/node @types/express @types/jsonwebtoken @types/bcrypt
Token Types: Access vs Refresh
| Token Type | Lifespan | Storage | Purpose |
|---|---|---|---|
| Access Token | 15 min | Memory/Header | API authorization |
| Refresh Token | 7 days | HttpOnly Cookie | Get new access token |
// config/jwt.ts
export const jwtConfig = {
accessToken: {
secret: process.env.JWT_ACCESS_SECRET!,
expiresIn: '15m',
},
refreshToken: {
secret: process.env.JWT_REFRESH_SECRET!,
expiresIn: '7d',
},
};
Token Generation
// lib/tokens.ts
import jwt from 'jsonwebtoken';
import { jwtConfig } from '../config/jwt';
interface TokenPayload {
userId: string;
email: string;
role: string;
}
export function generateAccessToken(payload: TokenPayload): string {
return jwt.sign(payload, jwtConfig.accessToken.secret, {
expiresIn: jwtConfig.accessToken.expiresIn,
issuer: 'your-app',
audience: 'your-app-users',
});
}
export function generateRefreshToken(payload: TokenPayload): string {
return jwt.sign(
{ userId: payload.userId }, // Minimal payload for refresh
jwtConfig.refreshToken.secret,
{
expiresIn: jwtConfig.refreshToken.expiresIn,
issuer: 'your-app',
}
);
}
export function verifyAccessToken(token: string): TokenPayload {
return jwt.verify(token, jwtConfig.accessToken.secret, {
issuer: 'your-app',
audience: 'your-app-users',
}) as TokenPayload;
}
export function verifyRefreshToken(token: string): { userId: string } {
return jwt.verify(token, jwtConfig.refreshToken.secret, {
issuer: 'your-app',
}) as { userId: string };
}
export function generateTokenPair(payload: TokenPayload) {
return {
accessToken: generateAccessToken(payload),
refreshToken: generateRefreshToken(payload),
};
}
Refresh Token Storage
For production, store refresh tokens in the database to enable revocation:
// models/refreshToken.ts
interface RefreshToken {
id: string;
token: string;
userId: string;
expiresAt: Date;
createdAt: Date;
revokedAt: Date | null;
replacedByToken: string | null;
}
// lib/refreshTokenStore.ts
import { db } from './database';
import crypto from 'crypto';
export async function createRefreshToken(userId: string): Promise<string> {
const token = crypto.randomBytes(40).toString('hex');
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await db.refreshTokens.create({
token: hashToken(token),
userId,
expiresAt,
});
return token;
}
export async function validateRefreshToken(token: string): Promise<RefreshToken | null> {
const hashedToken = hashToken(token);
const storedToken = await db.refreshTokens.findOne({
where: {
token: hashedToken,
revokedAt: null,
expiresAt: { gt: new Date() },
},
});
return storedToken;
}
export async function revokeRefreshToken(token: string): Promise<void> {
const hashedToken = hashToken(token);
await db.refreshTokens.update({
where: { token: hashedToken },
data: { revokedAt: new Date() },
});
}
export async function rotateRefreshToken(
oldToken: string,
userId: string
): Promise<string> {
const newToken = await createRefreshToken(userId);
// Mark old token as replaced
await db.refreshTokens.update({
where: { token: hashToken(oldToken) },
data: {
revokedAt: new Date(),
replacedByToken: hashToken(newToken),
},
});
return newToken;
}
// Revoke all tokens for a user (logout everywhere)
export async function revokeAllUserTokens(userId: string): Promise<void> {
await db.refreshTokens.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
Authentication Controller
// controllers/auth.ts
import { Request, Response } from 'express';
import bcrypt from 'bcrypt';
import { db } from '../lib/database';
import {
generateAccessToken,
verifyRefreshToken,
} from '../lib/tokens';
import {
createRefreshToken,
validateRefreshToken,
rotateRefreshToken,
revokeRefreshToken,
revokeAllUserTokens,
} from '../lib/refreshTokenStore';
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict' as const,
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/auth',
};
export async function register(req: Request, res: Response) {
const { email, password, name } = req.body;
// Check existing user
const existing = await db.users.findByEmail(email);
if (existing) {
return res.status(409).json({ error: 'Email already registered' });
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
const user = await db.users.create({
email,
password: hashedPassword,
name,
});
// Generate tokens
const payload = { userId: user.id, email: user.email, role: user.role };
const accessToken = generateAccessToken(payload);
const refreshToken = await createRefreshToken(user.id);
// Set refresh token in cookie
res.cookie('refreshToken', refreshToken, COOKIE_OPTIONS);
res.status(201).json({
user: { id: user.id, email: user.email, name: user.name },
accessToken,
});
}
export async function login(req: Request, res: Response) {
const { email, password } = req.body;
// Find user
const user = await db.users.findByEmail(email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Verify password
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate tokens
const payload = { userId: user.id, email: user.email, role: user.role };
const accessToken = generateAccessToken(payload);
const refreshToken = await createRefreshToken(user.id);
// Set refresh token in cookie
res.cookie('refreshToken', refreshToken, COOKIE_OPTIONS);
res.json({
user: { id: user.id, email: user.email, name: user.name },
accessToken,
});
}
export async function refresh(req: Request, res: Response) {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
// Validate stored token
const storedToken = await validateRefreshToken(refreshToken);
if (!storedToken) {
// Token reuse detected - revoke all user tokens
try {
const decoded = verifyRefreshToken(refreshToken);
await revokeAllUserTokens(decoded.userId);
} catch {
// Token is invalid
}
res.clearCookie('refreshToken', { path: '/api/auth' });
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Get user
const user = await db.users.findById(storedToken.userId);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
// Rotate tokens
const newRefreshToken = await rotateRefreshToken(refreshToken, user.id);
const payload = { userId: user.id, email: user.email, role: user.role };
const accessToken = generateAccessToken(payload);
res.cookie('refreshToken', newRefreshToken, COOKIE_OPTIONS);
res.json({ accessToken });
}
export async function logout(req: Request, res: Response) {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
await revokeRefreshToken(refreshToken);
}
res.clearCookie('refreshToken', { path: '/api/auth' });
res.json({ message: 'Logged out successfully' });
}
export async function logoutAll(req: Request, res: Response) {
const userId = req.user!.userId;
await revokeAllUserTokens(userId);
res.clearCookie('refreshToken', { path: '/api/auth' });
res.json({ message: 'Logged out from all devices' });
}
Authentication Middleware
// middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../lib/tokens';
declare global {
namespace Express {
interface Request {
user?: {
userId: string;
email: string;
role: string;
};
}
}
}
export function authenticate(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.slice(7);
try {
const payload = verifyAccessToken(token);
req.user = payload;
next();
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
export function requireRole(...roles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
Frontend Integration
// lib/api.ts
class ApiClient {
private accessToken: string | null = null;
private refreshPromise: Promise<void> | null = null;
setAccessToken(token: string) {
this.accessToken = token;
}
async request<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await this.fetchWithAuth(url, options);
if (response.status === 401) {
const data = await response.json();
if (data.code === 'TOKEN_EXPIRED') {
// Token expired - try to refresh
await this.refreshToken();
// Retry original request
return this.request(url, options);
}
// Other auth error - logout
this.logout();
throw new Error('Authentication failed');
}
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json();
}
private async fetchWithAuth(url: string, options: RequestInit): Promise<Response> {
const headers = new Headers(options.headers);
if (this.accessToken) {
headers.set('Authorization', `Bearer ${this.accessToken}`);
}
return fetch(url, { ...options, headers, credentials: 'include' });
}
private async refreshToken(): Promise<void> {
// Prevent multiple simultaneous refresh requests
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = (async () => {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
throw new Error('Refresh failed');
}
const { accessToken } = await response.json();
this.accessToken = accessToken;
} finally {
this.refreshPromise = null;
}
})();
return this.refreshPromise;
}
async login(email: string, password: string): Promise<User> {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include',
});
if (!response.ok) {
throw new Error('Login failed');
}
const { user, accessToken } = await response.json();
this.accessToken = accessToken;
return user;
}
async logout(): Promise<void> {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
this.accessToken = null;
}
}
export const api = new ApiClient();
Security Best Practices
1. Use Strong Secrets
# Generate secure secrets
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
2. Implement Rate Limiting
import rateLimit from 'express-rate-limit';
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: { error: 'Too many login attempts, please try again later' },
});
app.use('/api/auth/login', authLimiter);
3. Add CSRF Protection
import csrf from 'csurf';
const csrfProtection = csrf({ cookie: true });
// Apply to state-changing auth routes
app.post('/api/auth/logout', csrfProtection, logoutHandler);
4. Secure Cookie Settings
const COOKIE_OPTIONS = {
httpOnly: true, // Prevent XSS access
secure: true, // HTTPS only in production
sameSite: 'strict', // Prevent CSRF
path: '/api/auth', // Limit cookie scope
maxAge: 7 * 24 * 60 * 60 * 1000,
};
Conclusion
Secure JWT authentication requires:
- Short-lived access tokens: 15 minutes or less
- Refresh token rotation: New token on each use
- Secure storage: HttpOnly cookies for refresh tokens
- Token revocation: Database-backed invalidation
- Rate limiting: Prevent brute force attacks
Implement these patterns and your authentication will be production-ready.
Questions about JWT security? Ask in the comments!
In-Article Ad
Dev Mode
Mahmoud DEVO
Senior Full-Stack Developer
I'm a passionate full-stack developer with 10+ years of experience building scalable web applications. I write about Vue.js, Node.js, PostgreSQL, and modern DevOps practices.
Enjoyed this article?
Subscribe to get more tech content delivered to your inbox.
Related Articles
Building a Production-Ready REST API with Node.js and TypeScript
Learn to build scalable REST APIs with Node.js, Express, TypeScript, and PostgreSQL. Includes authentication, validation, and error handling.

Welcome to FullOpenAI Blog
Your go-to resource for in-depth technical articles on Vue.js, TypeScript, PostgreSQL, SaaS architecture, and DevOps - based on real-world experience and academic foundations.