Build a Secure JWT Authentication System in Node.js (2026)
Step-by-step guide to building JWT authentication in Node.js with access tokens, refresh tokens, httpOnly cookies, and security best practices for 2026.
Get more content like this on Telegram!
Daily AI tips, notes & resources β free
JWT authentication is one of those things that seems simple until you actually build it securely. The basic implementation β sign a token, verify a token β takes 20 minutes. Building one that handles token rotation, refresh logic, secure cookie storage, and proper invalidation takes considerably more thought.
I've seen a lot of JWT tutorials that show you the happy path and leave out all the security considerations. This isn't one of those. We're going to build the full system: access tokens, refresh tokens, httpOnly cookies, and middleware β and I'll call out every security pitfall along the way.
Understanding the JWT Architecture We're Building
Before writing code, it's worth being clear on what we're building and why each piece exists.
Access tokens are short-lived JWTs (15β60 minutes) that authenticate API requests. They're sent on every request, so if one leaks, the damage window is small.
Refresh tokens are long-lived (7β30 days), stored in an httpOnly cookie, and used only to get new access tokens. They never leave the server path that issues new tokens.
httpOnly cookies store refresh tokens where JavaScript can't access them β this eliminates the most common JWT theft vector (XSS attacks reading localStorage).
This is the industry-standard pattern used by companies like Auth0, Okta, and most mature authentication systems. Our JWT authentication guide covers the theory in more detail if you want the deeper context before the code.
Project Setup
mkdir jwt-auth-demo && cd jwt-auth-demo
npm init -y
npm install express jsonwebtoken bcryptjs cookie-parser pg dotenv
npm install -D typescript @types/node @types/express @types/jsonwebtoken @types/bcryptjs nodemon ts-node
Your .env file:
DATABASE_URL=postgres://user:password@localhost:5432/auth_demo
ACCESS_TOKEN_SECRET=your-256-bit-secret-here-make-it-long-and-random
REFRESH_TOKEN_SECRET=another-256-bit-secret-different-from-access
ACCESS_TOKEN_EXPIRES_IN=15m
REFRESH_TOKEN_EXPIRES_IN=7d
NODE_ENV=production
Security note: Use different secrets for access and refresh tokens. If one leaks, you can rotate just that secret without invalidating both token types.
Database Setup
-- PostgreSQL schema
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Store refresh tokens server-side for rotation tracking
CREATE TABLE refresh_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMP NOT NULL,
revoked BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
Storing refresh tokens in the database lets us revoke them explicitly. We store the hash of the token, not the token itself β same principle as password hashing.
Core Token Utilities
// src/utils/tokens.ts
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
interface AccessTokenPayload {
userId: number;
email: string;
}
export function generateAccessToken(payload: AccessTokenPayload): string {
return jwt.sign(payload, process.env.ACCESS_TOKEN_SECRET!, {
expiresIn: process.env.ACCESS_TOKEN_EXPIRES_IN || '15m',
issuer: 'aitechworlds-api',
audience: 'aitechworlds-client',
});
}
export function generateRefreshToken(): string {
// Refresh tokens are random bytes β not JWTs
// This makes them easier to revoke and avoids leaking user data
return crypto.randomBytes(64).toString('hex');
}
export function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
export function verifyAccessToken(token: string): AccessTokenPayload {
return jwt.verify(token, process.env.ACCESS_TOKEN_SECRET!, {
issuer: 'aitechworlds-api',
audience: 'aitechworlds-client',
}) as AccessTokenPayload;
}
Notice I'm making refresh tokens random bytes rather than JWTs. Some tutorials make refresh tokens JWTs too, but that complicates revocation without providing real benefits. Random bytes stored server-side are simpler and more secure.
Authentication Routes
// src/routes/auth.ts
import express from 'express';
import bcrypt from 'bcryptjs';
import { pool } from '../db';
import {
generateAccessToken,
generateRefreshToken,
hashToken,
verifyAccessToken,
} from '../utils/tokens';
const router = express.Router();
const REFRESH_TOKEN_COOKIE = 'refresh_token';
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict' as const,
path: '/api/auth', // Only sent to auth endpoints β not all routes
};
// POST /api/auth/register
router.post('/register', async (req, res) => {
const { email, password } = req.body;
if (!email || !password || password.length < 8) {
return res.status(400).json({ error: 'Valid email and password (8+ chars) required' });
}
try {
const existingUser = await pool.query(
'SELECT id FROM users WHERE email = $1', [email.toLowerCase()]
);
if (existingUser.rows.length > 0) {
return res.status(409).json({ error: 'Email already in use' });
}
// Cost factor 12 β a good balance of security vs performance in 2026
const passwordHash = await bcrypt.hash(password, 12);
const { rows } = await pool.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email',
[email.toLowerCase(), passwordHash]
);
const user = rows[0];
const { accessToken, refreshToken } = await issueTokenPair(user.id, user.email);
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
...cookieOptions,
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms
});
res.status(201).json({
user: { id: user.id, email: user.email },
accessToken,
});
} catch (err) {
console.error('Register error:', err);
res.status(500).json({ error: 'Registration failed' });
}
});
// POST /api/auth/login
router.post('/login', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
try {
const { rows } = await pool.query(
'SELECT id, email, password_hash FROM users WHERE email = $1',
[email.toLowerCase()]
);
// Always run bcrypt.compare to prevent timing attacks
// even when user doesn't exist
const dummyHash = '$2b$12$dummyHashForTimingAttackPrevention';
const user = rows[0];
const hashToCompare = user ? user.password_hash : dummyHash;
const isValid = await bcrypt.compare(password, hashToCompare);
if (!user || !isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const { accessToken, refreshToken } = await issueTokenPair(user.id, user.email);
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
...cookieOptions,
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({
user: { id: user.id, email: user.email },
accessToken,
});
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Login failed' });
}
});
// POST /api/auth/refresh β get a new access token using refresh token
router.post('/refresh', async (req, res) => {
const refreshToken = req.cookies[REFRESH_TOKEN_COOKIE];
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
const tokenHash = hashToken(refreshToken);
const { rows } = await pool.query(
`SELECT rt.*, u.email
FROM refresh_tokens rt
JOIN users u ON u.id = rt.user_id
WHERE rt.token_hash = $1
AND rt.revoked = FALSE
AND rt.expires_at > NOW()`,
[tokenHash]
);
if (!rows.length) {
// Token not found β possible theft detection
res.clearCookie(REFRESH_TOKEN_COOKIE, cookieOptions);
return res.status(401).json({ error: 'Invalid refresh token' });
}
const tokenRecord = rows[0];
// Refresh token rotation β revoke old, issue new
await pool.query(
'UPDATE refresh_tokens SET revoked = TRUE WHERE id = $1',
[tokenRecord.id]
);
const { accessToken, refreshToken: newRefreshToken } = await issueTokenPair(
tokenRecord.user_id,
tokenRecord.email
);
res.cookie(REFRESH_TOKEN_COOKIE, newRefreshToken, {
...cookieOptions,
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken });
} catch (err) {
console.error('Refresh error:', err);
res.status(500).json({ error: 'Token refresh failed' });
}
});
// POST /api/auth/logout
router.post('/logout', async (req, res) => {
const refreshToken = req.cookies[REFRESH_TOKEN_COOKIE];
if (refreshToken) {
const tokenHash = hashToken(refreshToken);
await pool.query(
'UPDATE refresh_tokens SET revoked = TRUE WHERE token_hash = $1',
[tokenHash]
).catch(console.error);
}
res.clearCookie(REFRESH_TOKEN_COOKIE, cookieOptions);
res.json({ message: 'Logged out successfully' });
});
// Helper function
async function issueTokenPair(userId: number, email: string) {
const accessToken = generateAccessToken({ userId, email });
const refreshToken = generateRefreshToken();
const tokenHash = hashToken(refreshToken);
await pool.query(
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '7 days')`,
[userId, tokenHash]
);
return { accessToken, refreshToken };
}
export default router;
Authentication Middleware
// src/middleware/authenticate.ts
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../utils/tokens';
export interface AuthRequest extends Request {
user?: { userId: number; email: string };
}
export function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access token required' });
}
const token = authHeader.substring(7);
try {
const payload = verifyAccessToken(token);
req.user = payload;
next();
} catch (err: any) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
// Usage in routes
app.get('/api/profile', authenticate, (req: AuthRequest, res) => {
res.json({ user: req.user });
});
The code: 'TOKEN_EXPIRED' in the response lets your frontend client know to attempt a token refresh rather than a full logout β this is what makes the UX feel smooth.
Security Pitfalls Table
| Vulnerability | Risk Level | Fix Applied |
|---|---|---|
| Storing JWT in localStorage | Critical | httpOnly cookie used instead |
| Same secret for access + refresh tokens | High | Separate secrets configured |
| No refresh token rotation | High | Old token revoked on each use |
| Timing attack on password compare | Medium | Always run bcrypt.compare even for unknown users |
| Long-lived access tokens (24h+) | High | 15-minute expiry with refresh flow |
| Refresh token sent on all requests | Medium | Cookie path restricted to /api/auth |
| No token revocation mechanism | Medium | Server-side token storage with revoke flag |
| JWT algorithm confusion (alg: none) | Critical | Algorithm explicitly set in verify options |
| Missing issuer/audience claims | Medium | iss and aud set and verified |
Client-Side Token Management
Here's a simple pattern for managing token refresh in the browser:
// client/api.ts
const API_BASE = 'https://api.yoursite.com';
let accessToken: string | null = null;
async function apiRequest(path: string, options: RequestInit = {}) {
const headers = new Headers(options.headers || {});
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`);
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: 'include', // Send cookies (refresh token)
});
if (res.status === 401) {
const body = await res.json();
if (body.code === 'TOKEN_EXPIRED') {
// Attempt refresh
const refreshRes = await fetch(`${API_BASE}/api/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
if (refreshRes.ok) {
const { accessToken: newToken } = await refreshRes.json();
accessToken = newToken;
headers.set('Authorization', `Bearer ${accessToken}`);
// Retry original request
return fetch(`${API_BASE}${path}`, { ...options, headers, credentials: 'include' });
}
// Refresh failed β user needs to log in again
window.location.href = '/login';
}
}
return res;
}
This pattern handles the full token refresh cycle transparently β users never see a "session expired" error during normal usage.
Connecting the App
// src/app.ts
import express from 'express';
import cookieParser from 'cookie-parser';
import authRoutes from './routes/auth';
import dotenv from 'dotenv';
dotenv.config();
const app = express();
app.use(express.json({ limit: '10kb' })); // Limit request body size
app.use(cookieParser());
// Security headers
app.use((req, res, next) => {
res.set('X-Content-Type-Options', 'nosniff');
res.set('X-Frame-Options', 'DENY');
res.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
next();
});
app.use('/api/auth', authRoutes);
app.listen(3000, () => console.log('Auth server running on :3000'));
For production, pair this with rate limiting on auth endpoints. The API security vulnerabilities guide covers rate limiting patterns specifically for authentication endpoints.
Wrapping Up
A secure JWT authentication system has more moving parts than most tutorials show. The critical pieces are: short-lived access tokens in memory, long-lived refresh tokens in httpOnly cookies, server-side refresh token storage for revocation, token rotation on each refresh, and timing attack prevention in your login route.
This implementation handles all of those. It's production-ready with some modifications β you'll want to add rate limiting, consider a Redis-backed token store for scale, and potentially add email verification depending on your use case.
Check our web dev roadmap 2026 if you're building the broader skills around this β authentication doesn't exist in isolation, and understanding Docker tutorial for beginners will help you deploy this system securely.
Frequently Asked Questions
Should I store JWT tokens in localStorage or cookies?
For browser-based applications, store JWTs in httpOnly cookies rather than localStorage. localStorage is accessible via JavaScript and vulnerable to XSS attacks. httpOnly cookies cannot be accessed by JavaScript, significantly reducing the attack surface.
What is the difference between access tokens and refresh tokens?
Access tokens are short-lived (15β60 minutes) and used to authenticate API requests. Refresh tokens are long-lived (7β30 days) and stored securely β they're used only to obtain new access tokens when the current one expires, without requiring the user to log in again.
How do I invalidate a JWT before it expires?
JWTs are stateless by design β you can't invalidate them server-side without adding state. The most common approach is maintaining a token blocklist (in Redis) for revoked tokens, or using short-lived access tokens so compromised tokens expire quickly. Refresh token rotation also limits the damage window.
Frequently Asked Questions
AiTechWorlds Team
β Verified WriterThe AiTechWorlds team is passionate about AI, technology, and education. We create high-quality, research-backed content to help you learn, grow, and succeed in the modern digital world.
Related Articles
How to Use Docker Compose for Local Dev (Node.js + PostgreSQL)
Set up a full local dev environment with Docker Compose, Node.js, PostgreSQL, and pgAdmin. Includes .env config, named volumes, healthchecks, and common error fixes.
5 GraphQL Resolver Best Practices (DataLoader, Error Handling)
Write efficient GraphQL resolvers that don't hammer your database. DataLoader N+1 fix, error handling patterns, auth in context, and resolver performance comparison.
7 API Authentication Methods Compared (API Key, JWT, OAuth, Basic)
Compare 7 API authentication methods β API keys, JWT, OAuth 2.0, Basic Auth, HMAC, mTLS, and session tokens β with code examples and a security tradeoff table.
7 Common API Security Vulnerabilities (and How to Fix Them)
Real API security vulnerabilities from the OWASP API Top 10 β with working code fixes, risk levels, and testing tools so you can protect your APIs today.