Authentication with JWT: Complete Implementation Guide
You've built a beautiful API, but now you face the critical challenge every developer encounters: How do you securely authenticate users? How do you ensure that only authorized users can access protected resources? How do you maintain sessions without the overhead of server-side session storage?
Enter JSON Web Tokens (JWT)βthe industry-standard solution for stateless authentication that has revolutionized how we build secure APIs. In this comprehensive guide, we'll explore everything you need to know about JWT authentication, from understanding the fundamentals to implementing a production-ready solution.
JWTs enable stateless authentication, meaning the server doesn't need to store session data. This makes your application more scalable, easier to deploy across multiple servers, and perfect for microservices architectures.
What is a JSON Web Token?
A JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. Think of it as a digitally signed passport that contains information about the user and can be verified without contacting the issuing authority.
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
Understanding the authentication flow is crucial for implementing JWT correctly. Here's how the process works from login to accessing protected resources:
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
Let's implement a complete JWT authentication system using Node.js and Express. We'll cover user registration, login, token generation, and protected routes.
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
Never commit your .env file to version control! Add it to .gitignore. In production, use environment variables provided by your hosting platform. Your JWT secret should be at least 256 bits (32 characters) of random data.
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 };
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
A robust JWT implementation uses two types of tokens for enhanced security. Understanding their differences is crucial:
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
Here's how to handle JWT authentication on the frontend, including automatic token refresh:
// 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;
Security Best Practices
JWT authentication is powerful but can be vulnerable if implemented incorrectly. Follow these security best practices:
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
Never accept the algorithm from the token itself without validation. An attacker could change RS256 to HS256 and sign with a public key. Always specify allowed algorithms explicitly:
jwt.verify(token, secret, { algorithms: ['HS256'] }); // β Safe
JWT vs Session-Based Auth
Both approaches have their place. Here's a comparison to help you choose:
| 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
One challenge with JWTs is revocation. Since tokens are self-contained, the server can't invalidate them easily. Here are strategies to handle this:
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
Use these cURL commands to test your JWT authentication endpoints:
# 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
Before deploying your JWT authentication to production, verify these items:
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
Conclusion
JWT authentication provides a powerful, scalable solution for modern applications. By understanding how tokens work, implementing proper security measures, and following best practices, you can build authentication systems that are both user-friendly and secure.
Key takeaways:
- JWTs are self-contained tokens with header, payload, and signature
- Use short-lived access tokens with long-lived refresh tokens
- Store refresh tokens in httpOnly cookies, not localStorage
- Always verify tokens server-side with explicit algorithm specification
- Implement token refresh logic on the frontend for seamless UX
- Consider revocation strategies based on your security requirements
With this guide, you have everything you need to implement robust JWT authentication in your Node.js applications. Happy coding! π
