JWT Token-Based Authentication
Implement a user authentication system from scratch using JWT (JSON Web Token) in a SPA (Single Page Application) environment without server-side sessions.
Problem
Required Tools
Server-side runtime environment
Middleware-based web framework
JWT creation and verification library (npm package)
Password hashing library
Solution Steps
Understanding JWT Structure and Project Setup
JWT is a string consisting of three parts connected by dots (.): Header.Payload.Signature. - Header: Algorithm (HS256, etc.) and type (JWT) information - Payload: User information (claims). sub (subject), iat (issued at), exp (expiration), etc. - Signature: Header + Payload signed with a secret key. Used for tamper verification Important: The Payload is only Base64-encoded, NOT encrypted. Never include sensitive information (passwords, etc.) in it. Initialize the project and install the required packages.
# Initialize project
mkdir jwt-auth-demo && cd jwt-auth-demo
npm init -y
# Install dependencies
npm install express jsonwebtoken bcryptjs dotenv cookie-parser
npm install -D @types/express @types/jsonwebtoken typescript ts-node
# Create .env file (secret keys must be managed via environment variables!)
cat > .env << 'EOF'
ACCESS_TOKEN_SECRET=your-access-secret-key-min-32-chars-long
REFRESH_TOKEN_SECRET=your-refresh-secret-key-min-32-chars-long
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
PORT=3000
EOFIssue Access Token and Refresh Token on Login
When a user logs in, two types of tokens are issued: - Access Token: Short-lived (15 minutes). Used for authentication on API requests. Even if stolen, it expires quickly - Refresh Token: Long-lived (7 days). Used only to renew Access Tokens. Stored in an HttpOnly cookie to defend against XSS The most secure approach is to deliver the Access Token in the response body (JSON) and the Refresh Token in an HttpOnly/Secure cookie.
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const cookieParser = require('cookie-parser');
require('dotenv').config();
const app = express();
app.use(express.json());
app.use(cookieParser());
// Temporary user DB (use a real database in production)
const users = [];
// Refresh Token store (use Redis or similar in production)
const refreshTokens = new Set();
// Token generation functions
function generateAccessToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: process.env.ACCESS_TOKEN_EXPIRY } // 15 minutes
);
}
function generateRefreshToken(user) {
return jwt.sign(
{ sub: user.id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: process.env.REFRESH_TOKEN_EXPIRY } // 7 days
);
}
// Register
app.post('/api/register', async (req, res) => {
const { email, password, name } = req.body;
// Hash password (never store plaintext!)
const hashedPassword = await bcrypt.hash(password, 12);
const user = { id: Date.now().toString(), email, password: hashedPassword, name, role: 'user' };
users.push(user);
res.status(201).json({ message: 'Registration successful' });
});
// Login - Issue tokens
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
const user = users.find(u => u.email === email);
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ error: 'Invalid email or password' });
}
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// Store Refresh Token (needed for invalidation on logout)
refreshTokens.add(refreshToken);
// Deliver Refresh Token as HttpOnly cookie (XSS defense)
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // Sent only over HTTPS
sameSite: 'strict', // CSRF defense
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
// Deliver Access Token in response body
res.json({ accessToken, user: { id: user.id, email: user.email, name: user.name } });
});Verify Tokens with Authentication Middleware
Create middleware to verify the Access Token when accessing protected API endpoints. The client sends the Access Token in the Authorization header in the format 'Bearer {token}'. The middleware checks the signature validity and expiration of the token, and if valid, attaches user information to req.user and passes it to the next handler.
// Authentication middleware
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"
if (!token) {
return res.status(401).json({ error: 'Authentication token required' });
}
try {
const decoded = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
req.user = decoded; // { sub, email, role, iat, exp }
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token has expired', code: 'TOKEN_EXPIRED' });
}
return res.status(403).json({ error: 'Invalid token' });
}
}
// Role-based authorization middleware
function requireRole(...roles) {
return (req, res, next) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Protected API endpoint
app.get('/api/profile', authenticateToken, (req, res) => {
const user = users.find(u => u.id === req.user.sub);
res.json({ id: user.id, email: user.email, name: user.name, role: user.role });
});
// Admin-only endpoint
app.get('/api/admin/users', authenticateToken, requireRole('admin'), (req, res) => {
res.json(users.map(u => ({ id: u.id, email: u.email, role: u.role })));
});Renew Access Token Using Refresh Token
When the Access Token expires, use the Refresh Token to obtain a new Access Token. Applying "Token Rotation," where the Refresh Token is also renewed at the same time, allows detection of a stolen Refresh Token if it is reused. Renewal flow: 1. Client calls the /api/refresh endpoint 2. Server extracts the Refresh Token from the cookie and verifies it 3. Server invalidates the old Refresh Token and issues a new token pair 4. New Refresh Token goes in the cookie, new Access Token goes in the response body
// Access Token renewal endpoint
app.post('/api/refresh', (req, res) => {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh Token not found' });
}
// Check if it is a valid token in the store
if (!refreshTokens.has(refreshToken)) {
// Reuse detected in Token Rotation - invalidate all tokens (security measure)
return res.status(403).json({ error: 'Invalid Refresh Token. Please log in again.' });
}
try {
const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
const user = users.find(u => u.id === decoded.sub);
if (!user) {
return res.status(403).json({ error: 'User not found' });
}
// Invalidate old Refresh Token (Token Rotation)
refreshTokens.delete(refreshToken);
// Issue new token pair
const newAccessToken = generateAccessToken(user);
const newRefreshToken = generateRefreshToken(user);
refreshTokens.add(newRefreshToken);
// Set new Refresh Token in cookie
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken: newAccessToken });
} catch (err) {
refreshTokens.delete(refreshToken);
return res.status(403).json({ error: 'Refresh Token has expired. Please log in again.' });
}
});
// Logout - Invalidate token
app.post('/api/logout', (req, res) => {
const { refreshToken } = req.cookies;
if (refreshToken) {
refreshTokens.delete(refreshToken);
}
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
});
app.listen(process.env.PORT, () => {
console.log(`Server running: http://localhost:${process.env.PORT}`);
});Frontend Token Management and Auto-Renewal
On the frontend (React, Vue, etc.), store the Access Token in memory and automatically attach it to API requests. When the Access Token expires, automatically attempt renewal, and redirect to the login page if renewal fails. Using Axios interceptors enables automated token management for all API calls. Important security principles: - Store Access Token only in memory (variables) - do NOT use localStorage (vulnerable to XSS) - Manage Refresh Token only as an HttpOnly cookie (not accessible via JavaScript) - On page refresh, re-issue the Access Token via /api/refresh
// api.js - Axios interceptor-based token management
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
withCredentials: true, // Automatically send cookies
});
// Store Access Token only in memory (security)
let accessToken = null;
// Request interceptor: Automatically attach Access Token
api.interceptors.request.use((config) => {
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// Response interceptor: Automatically attempt token renewal on 401 errors
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token) => {
failedQueue.forEach(({ resolve, reject }) => {
error ? reject(error) : resolve(token);
});
failedQueue = [];
};
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If 401 and not yet retried, attempt token renewal
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// If another request is already renewing, queue this one
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const { data } = await axios.post('/api/refresh', {}, { withCredentials: true });
accessToken = data.accessToken;
processQueue(null, accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
accessToken = null;
window.location.href = '/login'; // Redirect to login page
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
// Login function
export async function login(email, password) {
const { data } = await api.post('/login', { email, password });
accessToken = data.accessToken;
return data.user;
}
// Logout function
export async function logout() {
await api.post('/logout');
accessToken = null;
}
export default api;Core Code
Core code summary for JWT token creation, verification, and Express middleware
const jwt = require('jsonwebtoken');
// ========== Token Creation ==========
const accessToken = jwt.sign(
{ sub: userId, email, role }, // payload
process.env.ACCESS_TOKEN_SECRET, // secret key
{ expiresIn: '15m' } // options
);
const refreshToken = jwt.sign(
{ sub: userId },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
// ========== Token Verification ==========
try {
const decoded = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
// decoded = { sub, email, role, iat, exp }
console.log('Authentication successful:', decoded.email);
} catch (err) {
if (err.name === 'TokenExpiredError') {
console.log('Token expired - renewal via Refresh Token required');
} else if (err.name === 'JsonWebTokenError') {
console.log('Token tampering detected');
}
}
// ========== Express Middleware ==========
function auth(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Authentication required' });
try {
req.user = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
next();
} catch { return res.status(401).json({ error: 'Authentication failed' }); }
}Common Mistakes
Storing Access Token in localStorage
localStorage is accessible via JavaScript, making it vulnerable to XSS attacks. Store Access Tokens only in memory (JavaScript variables) and manage Refresh Tokens via HttpOnly cookies. On page refresh, re-issue the token via the /refresh endpoint.
Hardcoding the secret key in the source code
If the secret key is exposed in source code, tokens can be freely forged. Always manage keys via environment variables (.env) and add .env to .gitignore. In production, use a secrets management service like AWS Secrets Manager.
Not setting token expiration or setting it too long
Access Tokens should be 15 minutes to 1 hour, Refresh Tokens 7 to 30 days. Tokens without expiration can be exploited permanently if stolen. Set appropriate expiration times based on your security requirements.
Not revoking Refresh Tokens on logout
Refresh Tokens must be deleted from the server-side store (Redis, DB) on logout. Otherwise, token renewal remains possible even after logout. Applying the Token Rotation pattern also enables theft detection.