Authentication with JWT: Complete Implementation Guide
Your Express API works fine with one server and cookie sessions. Then a mobile app needs to call it, or you put a second instance behind a load balancer, and suddenly users get logged out at random because the session lives in one process's memory. That's usually the point where someone suggests JSON Web Tokens.
This post walks through how a JWT is built, the login and refresh flow, a full Node.js + Express implementation with access and refresh tokens, the frontend side, and the security mistakes that show up most often. I'll also cover revocation, which is where JWTs get awkward.
The server can verify a JWT with just a secret or public key, so it doesn't need to look up a session on every request. That makes it easier to run several API instances or split auth across services. The cost is that you lose easy logout, which we'll get to.
What is a JSON Web Token?
Think of a JWT as a signed note: it carries a few facts about the user (the "claims"), and anyone holding the right key can check that nobody changed them, without calling back to whoever issued it. The format is compact and URL-safe, so it fits in an HTTP header.
A JWT consists of three parts, separated by dots (.):
Algorithm & token type
Claims (user data)
Verification hash
Anatomy of a JWT
How the three parts of a JWT are created and combined
JWT Authentication Flow
A lot of JWT bugs come from getting this flow slightly wrong, so here's the whole thing from login to a protected request:
Complete JWT authentication flow from login to token storage
1. Login
User sends credentials
2. Verify
Server validates user
3. Generate
Create signed JWT
4. Return
Send token to client
5. Store
Client saves token
6. Use
Include in requests
Implementation: Node.js + Express
The example below covers registration, login, token generation, refresh, and protected routes. It uses an in-memory user store so you can run it without a database.
Step 1: Project Setup
# 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 nodemon
Step 2: Environment Configuration
# JWT Configuration
JWT_SECRET=your-super-secret-key-min-32-chars-long
JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=your-refresh-secret-key-different-from-access
JWT_REFRESH_EXPIRES_IN=7d
# Server Configuration
PORT=3000
NODE_ENV=development
Don't commit your .env file. Add it to .gitignore and use your hosting platform's environment variables in production. The secret should be at least 256 bits of random data; openssl rand -base64 32 gives you one. A memorable phrase padded to 32 characters doesn't count.
Step 3: User Model & Authentication Logic
const bcrypt = require('bcryptjs');
// In-memory store (use MongoDB/PostgreSQL in production)
const users = new Map();
class User {
constructor(id, email, password, name) {
this.id = id;
this.email = email;
this.password = password;
this.name = name;
this.createdAt = new Date();
}
// Hash password before saving
static async create(email, password, name) {
const existingUser = Array.from(users.values())
.find(u => u.email === email);
if (existingUser) {
throw new Error('User already exists');
}
const hashedPassword = await bcrypt.hash(password, 12);
const id = `user_${Date.now()}`;
const user = new User(id, email, hashedPassword, name);
users.set(id, user);
return user;
}
// Find user by email
static findByEmail(email) {
return Array.from(users.values())
.find(u => u.email === email);
}
// Find user by ID
static findById(id) {
return users.get(id);
}
// Verify password
async comparePassword(candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
}
// Return safe user object (no password)
toJSON() {
return {
id: this.id,
email: this.email,
name: this.name,
createdAt: this.createdAt
};
}
}
module.exports = User;
Step 4: JWT Utilities
const jwt = require('jsonwebtoken');
// Generate Access Token (short-lived)
const generateAccessToken = (user) => {
return jwt.sign(
{
id: user.id,
email: user.email,
type: 'access'
},
process.env.JWT_SECRET,
{
expiresIn: process.env.JWT_EXPIRES_IN || '15m',
issuer: 'your-app-name',
audience: 'your-app-users'
}
);
};
// Generate Refresh Token (long-lived)
const generateRefreshToken = (user) => {
return jwt.sign(
{
id: user.id,
type: 'refresh'
},
process.env.JWT_REFRESH_SECRET,
{
expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
issuer: 'your-app-name'
}
);
};
// Verify Access Token
const verifyAccessToken = (token) => {
try {
return jwt.verify(token, process.env.JWT_SECRET);
} catch (error) {
if (error.name === 'TokenExpiredError') {
throw new Error('Token expired');
}
if (error.name === 'JsonWebTokenError') {
throw new Error('Invalid token');
}
throw error;
}
};
// Verify Refresh Token
const verifyRefreshToken = (token) => {
try {
return jwt.verify(token, process.env.JWT_REFRESH_SECRET);
} catch (error) {
throw new Error('Invalid refresh token');
}
};
// Decode token without verification (for debugging)
const decodeToken = (token) => {
return jwt.decode(token);
};
module.exports = {
generateAccessToken,
generateRefreshToken,
verifyAccessToken,
verifyRefreshToken,
decodeToken
};
Step 5: Authentication Middleware
const { verifyAccessToken } = require('../utils/jwt');
const User = require('../models/User');
// Protect routes - require valid JWT
const protect = async (req, res, next) => {
try {
let token;
// Get token from Authorization header
if (req.headers.authorization?.startsWith('Bearer')) {
token = req.headers.authorization.split(' ')[1];
}
// Or from cookies (for web apps)
else if (req.cookies?.accessToken) {
token = req.cookies.accessToken;
}
if (!token) {
return res.status(401).json({
success: false,
message: 'Access denied. No token provided.'
});
}
// Verify token
const decoded = verifyAccessToken(token);
// Get user from database
const user = User.findById(decoded.id);
if (!user) {
return res.status(401).json({
success: false,
message: 'User no longer exists.'
});
}
// Attach user to request
req.user = user;
next();
} catch (error) {
const message = error.message === 'Token expired'
? 'Token expired. Please refresh your token.'
: 'Invalid token. Please log in again.';
return res.status(401).json({
success: false,
message,
code: error.message === 'Token expired' ? 'TOKEN_EXPIRED' : 'INVALID_TOKEN'
});
}
};
// Optional auth - attach user if token present, continue otherwise
const optionalAuth = async (req, res, next) => {
try {
let token = req.headers.authorization?.split(' ')[1] || req.cookies?.accessToken;
if (token) {
const decoded = verifyAccessToken(token);
req.user = User.findById(decoded.id);
}
} catch (error) {
// Token invalid but we continue anyway
req.user = null;
}
next();
};
module.exports = { protect, optionalAuth };
If your logs show JsonWebTokenError: jwt malformed, check what the client actually sent. Often the header is literally Authorization: Bearer undefined or Bearer null, because the frontend read the token from storage before login finished. jwt expired and invalid signature are different problems: the first is normal and should trigger a refresh, the second usually means two environments are signing with different secrets.
Step 6: Auth Controller
const User = require('../models/User');
const {
generateAccessToken,
generateRefreshToken,
verifyRefreshToken
} = require('../utils/jwt');
// Cookie options
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
};
// @desc Register new user
// @route POST /api/auth/register
const register = async (req, res) => {
try {
const { email, password, name } = req.body;
// Validation
if (!email || !password || !name) {
return res.status(400).json({
success: false,
message: 'Please provide email, password, and name'
});
}
if (password.length < 8) {
return res.status(400).json({
success: false,
message: 'Password must be at least 8 characters'
});
}
// Create user
const user = await User.create(email, password, name);
// Generate tokens
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// Set refresh token in cookie
res.cookie('refreshToken', refreshToken, cookieOptions);
res.status(201).json({
success: true,
message: 'User registered successfully',
data: {
user: user.toJSON(),
accessToken,
expiresIn: process.env.JWT_EXPIRES_IN || '15m'
}
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message
});
}
};
// @desc Login user
// @route POST /api/auth/login
const login = async (req, res) => {
try {
const { email, password } = req.body;
// Validation
if (!email || !password) {
return res.status(400).json({
success: false,
message: 'Please provide email and password'
});
}
// Find user
const user = User.findByEmail(email);
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid credentials'
});
}
// Check password
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return res.status(401).json({
success: false,
message: 'Invalid credentials'
});
}
// Generate tokens
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// Set refresh token in cookie
res.cookie('refreshToken', refreshToken, cookieOptions);
res.json({
success: true,
message: 'Login successful',
data: {
user: user.toJSON(),
accessToken,
expiresIn: process.env.JWT_EXPIRES_IN || '15m'
}
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Server error'
});
}
};
// @desc Refresh access token
// @route POST /api/auth/refresh
const refreshToken = async (req, res) => {
try {
const token = req.cookies.refreshToken || req.body.refreshToken;
if (!token) {
return res.status(401).json({
success: false,
message: 'Refresh token required'
});
}
// Verify refresh token
const decoded = verifyRefreshToken(token);
// Get user
const user = User.findById(decoded.id);
if (!user) {
return res.status(401).json({
success: false,
message: 'User not found'
});
}
// Generate new access token
const accessToken = generateAccessToken(user);
res.json({
success: true,
data: {
accessToken,
expiresIn: process.env.JWT_EXPIRES_IN || '15m'
}
});
} catch (error) {
res.status(401).json({
success: false,
message: 'Invalid refresh token'
});
}
};
// @desc Logout user
// @route POST /api/auth/logout
const logout = (req, res) => {
res.cookie('refreshToken', '', {
httpOnly: true,
expires: new Date(0)
});
res.json({
success: true,
message: 'Logged out successfully'
});
};
// @desc Get current user
// @route GET /api/auth/me
const getMe = (req, res) => {
res.json({
success: true,
data: {
user: req.user.toJSON()
}
});
};
module.exports = { register, login, refreshToken, logout, getMe };
Step 7: Express Server Setup
require('dotenv').config();
const express = require('express');
const cookieParser = require('cookie-parser');
const { protect } = require('./middleware/auth');
const authController = require('./controllers/authController');
const app = express();
// Middleware
app.use(express.json());
app.use(cookieParser());
// Auth Routes
app.post('/api/auth/register', authController.register);
app.post('/api/auth/login', authController.login);
app.post('/api/auth/refresh', authController.refreshToken);
app.post('/api/auth/logout', authController.logout);
app.get('/api/auth/me', protect, authController.getMe);
// Protected Route Example
app.get('/api/protected', protect, (req, res) => {
res.json({
success: true,
message: 'You have access to this protected resource!',
user: req.user.toJSON()
});
});
// Public Route
app.get('/api/public', (req, res) => {
res.json({
success: true,
message: 'This is a public endpoint'
});
});
// Error Handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
success: false,
message: 'Internal server error'
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`π Server running on port ${PORT}`);
});
Access Tokens vs Refresh Tokens
The code above issues two tokens, not one. A single long-lived token is convenient until it leaks, and then the attacker has it for days. Splitting it in two keeps the token that travels on every request short-lived:
Access Token
Short-lived token used to access protected resources. Sent with every API request.
β±οΈ Lifetime: 15 minutes- Contains user claims
- Stored in memory (frontend)
- If stolen, limited damage window
Refresh Token
Long-lived token used only to obtain new access tokens. Stored securely.
β±οΈ Lifetime: 7 days- Minimal payload (just user ID)
- Stored in httpOnly cookie
- Can be revoked server-side
Access tokens expire frequently; refresh tokens are used to get new access tokens
Frontend Implementation
On the client you need two things: attach the access token to requests, and quietly get a new one when the API returns 401. Three versions below:
// hooks/useAuth.js
import { createContext, useContext, useState, useCallback } from 'react';
import api from '../utils/api';
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const login = async (email, password) => {
const { data } = await api.post('/auth/login', { email, password });
setUser(data.data.user);
localStorage.setItem('accessToken', data.data.accessToken);
return data;
};
const logout = async () => {
await api.post('/auth/logout');
setUser(null);
localStorage.removeItem('accessToken');
};
const refreshToken = useCallback(async () => {
try {
const { data } = await api.post('/auth/refresh');
localStorage.setItem('accessToken', data.data.accessToken);
return data.data.accessToken;
} catch (error) {
logout();
throw error;
}
}, []);
return (
<AuthContext.Provider value={{ user, login, logout, refreshToken, loading }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);
// auth.js - Vanilla JavaScript
class AuthService {
constructor() {
this.accessToken = localStorage.getItem('accessToken');
}
async login(email, password) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include' // Important for cookies
});
const data = await response.json();
if (data.success) {
this.accessToken = data.data.accessToken;
localStorage.setItem('accessToken', this.accessToken);
}
return data;
}
async fetchWithAuth(url, options = {}) {
const headers = {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
};
let response = await fetch(url, { ...options, headers });
// If token expired, try to refresh
if (response.status === 401) {
const refreshed = await this.refreshToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${this.accessToken}`;
response = await fetch(url, { ...options, headers });
}
}
return response;
}
async refreshToken() {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
});
const data = await response.json();
if (data.success) {
this.accessToken = data.data.accessToken;
localStorage.setItem('accessToken', this.accessToken);
return true;
}
return false;
} catch {
this.logout();
return false;
}
}
logout() {
this.accessToken = null;
localStorage.removeItem('accessToken');
window.location.href = '/login';
}
}
export default new AuthService();
// utils/api.js - Axios with interceptors
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
withCredentials: true // Send cookies
});
// Request interceptor - add access token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor - handle token refresh
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If 401 and not already retrying
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Attempt to refresh token
const { data } = await axios.post('/api/auth/refresh', {}, {
withCredentials: true
});
const newToken = data.data.accessToken;
localStorage.setItem('accessToken', newToken);
// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return api(originalRequest);
} catch (refreshError) {
// Refresh failed - redirect to login
localStorage.removeItem('accessToken');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default api;
The snippets keep the access token in localStorage because it's the simplest thing to show. Any XSS bug on your site can read it, though. For a real app I'd keep the access token in a JavaScript variable (memory) and rely on the httpOnly refresh cookie to get a new one after a page reload. Also note that with two requests failing at once, the Axios interceptor will fire two refresh calls; if you rotate refresh tokens, queue concurrent requests behind a single refresh promise.
Security Best Practices
Most JWT security problems aren't in the library. They come from how the tokens are configured and stored:
DO
- Use strong, random secrets (256+ bits)
- Set short expiration for access tokens (15 min)
- Store refresh tokens in httpOnly cookies
- Use HTTPS in production
- Validate all claims on the server
- Implement token rotation
- Use RS256 for distributed systems
DON'T
- Store tokens in localStorage (XSS vulnerable)
- Include sensitive data in payload
- Use weak or predictable secrets
- Skip signature verification
- Use long expiration for access tokens
- Forget to validate 'iss' and 'aud' claims
- Trust the token without verification
Don't let the token's own header decide which algorithm to verify with. If your server expects RS256, an attacker can switch the header to HS256 and sign the token using your public key as the HMAC secret, which a naive verifier will accept. Pin the allowed algorithms:
jwt.verify(token, secret, { algorithms: ['HS256'] }); // β Safe
JWT vs Session-Based Auth
JWTs aren't automatically better than sessions. Here's how they compare:
| Aspect | JWT (Stateless) | Sessions (Stateful) |
|---|---|---|
| Storage | Client-side (token) | Server-side (session store) |
| Scalability | Excellent (no shared state) | Requires sticky sessions or shared store |
| Revocation | Difficult (need blacklist) | Easy (delete from store) |
| Size | Larger (contains claims) | Small (just session ID) |
| Mobile/API | Excellent | Good (with cookie support) |
| Microservices | Excellent | Complex (shared session store) |
| Best For | APIs, SPAs, Mobile, Microservices | Traditional web apps, Simple setups |
Token Revocation Strategies
Here's the awkward part. A signed token stays valid until it expires, so "log this user out now" or "this account was just banned" needs extra work. Three common options:
1. Short Expiration + Refresh Tokens
The most common approach. Keep access tokens short-lived (15 min) so any compromised token has limited validity.
// Access token: 15 minutes
// Refresh token: 7 days (stored in DB for revocation)
// To "logout" everywhere:
// 1. Delete all refresh tokens for user from DB
// 2. User must re-authenticate after access token expires
2. Token Blacklist / Deny List
Store revoked token IDs (jti claim) in a fast cache like Redis. Check on every request.
// In middleware
const isRevoked = await redis.get(`revoked:${decoded.jti}`);
if (isRevoked) {
throw new Error('Token has been revoked');
}
// When revoking
await redis.setex(`revoked:${tokenId}`, TOKEN_TTL, '1');
β οΈ Adds state, but necessary for instant revocation.
3. Token Versioning
Include a version number in the token and user record. Increment when logging out everywhere.
// In token payload
{ userId: "123", tokenVersion: 5 }
// In database
{ userId: "123", tokenVersion: 5 }
// Validation
if (decoded.tokenVersion !== user.tokenVersion) {
throw new Error('Token version mismatch - please re-authenticate');
}
Testing Your Implementation
You can exercise every endpoint with cURL. The -c and -b flags save and send the refresh cookie:
# Register a new user
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"password123","name":"Test User"}'
# Login
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"password123"}' \
-c cookies.txt
# Access protected route
curl http://localhost:3000/api/protected \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
# Refresh token (using cookie)
curl -X POST http://localhost:3000/api/auth/refresh \
-b cookies.txt
# Get current user
curl http://localhost:3000/api/auth/me \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
# Logout
curl -X POST http://localhost:3000/api/auth/logout \
-b cookies.txt -c cookies.txt
Production Checklist
Go through this list before the auth code ships:
Pre-Production Checklist
- β Strong, unique JWT secrets (256+ bits)
- β Secrets stored in environment variables
- β HTTPS enabled and enforced
- β Secure cookie settings (httpOnly, secure, sameSite)
- β Short access token expiration (β€15 min)
- β Refresh token rotation implemented
- β Rate limiting on auth endpoints
- β Password hashing (bcrypt, cost factor 12+)
- β Input validation and sanitization
- β Error messages don't leak information
- β Logging for security events
- β Token revocation strategy in place
When I'd use JWT (and when I wouldn't)
I'd pick JWTs when the API is consumed by mobile apps or third parties, when several services need to verify the same user without sharing a session store, or when you're already using an identity provider that issues them (Auth0, Cognito, Keycloak and friends).
I wouldn't use them for a classic server-rendered app on one domain, like a typical Laravel or Express site with Blade or EJS views. Sessions with a Redis store are simpler there, logout actually logs people out, and the cookie handling is already done for you. If you end up adding a deny list to every request anyway, you've rebuilt sessions with extra steps.
The thing to settle before writing any code is revocation: how fast do you need a stolen or banned user's access to stop? If "within 15 minutes" is acceptable, short access tokens plus revocable refresh tokens are enough. If it has to be instant, budget for a server-side check on every request, and weigh that against plain sessions.