JWT Authentication: Token-Based Auth Guide
JSON Web Tokens (JWT) have become the de facto standard for stateless authentication in modern web applications. Unlike traditional session-based authentication where the server maintains session...
Key Insights
- JWTs enable stateless authentication by encoding user data in cryptographically signed tokens, eliminating the need for server-side session storage and making horizontal scaling trivial.
- Implement a dual-token strategy with short-lived access tokens (15 minutes) and long-lived refresh tokens (7 days) to balance security and user experience.
- Store refresh tokens in httpOnly cookies and access tokens in memory to prevent XSS attacks while maintaining a smooth authentication flow.
Introduction to JWT Authentication
JSON Web Tokens (JWT) have become the de facto standard for stateless authentication in modern web applications. Unlike traditional session-based authentication where the server maintains session state in memory or a database, JWTs are self-contained tokens that carry all necessary user information within themselves.
A JWT consists of three parts separated by dots: header.payload.signature. The header specifies the token type and hashing algorithm, the payload contains claims (user data), and the signature ensures the token hasn’t been tampered with.
// Decode a JWT to see its structure
const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NSIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImlhdCI6MTYzMjE1MDQwMCwiZXhwIjoxNjMyMTU0MDAwfQ.abc123signature";
// Split and decode each part
const [header, payload, signature] = jwt.split('.');
const decodedHeader = JSON.parse(Buffer.from(header, 'base64').toString());
const decodedPayload = JSON.parse(Buffer.from(payload, 'base64').toString());
console.log('Header:', decodedHeader);
// { alg: "HS256", typ: "JWT" }
console.log('Payload:', decodedPayload);
// { userId: "12345", email: "user@example.com", iat: 1632150400, exp: 1632154000 }
Use JWT authentication when you need stateless sessions, are building microservices, or require authentication across multiple domains. Stick with session-based auth for monolithic applications where you need server-side session revocation capabilities.
Implementing JWT Authentication in Node.js/Express
Let’s build a complete JWT authentication system. First, install the required dependencies:
npm install express jsonwebtoken bcrypt dotenv
Here’s a login endpoint that validates credentials and issues a JWT:
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
require('dotenv').config();
const app = express();
app.use(express.json());
// Mock user database
const users = [
{
id: '1',
email: 'user@example.com',
// Password: "password123" hashed
passwordHash: '$2b$10$rXQ3Y8Z9vVvKxVqYqXqXqe7Z8Z9vVvKxVqYqXqXqe7Z8Z9vVvKxVq'
}
];
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
// Find user
const user = users.find(u => u.email === email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Verify password
const validPassword = await bcrypt.compare(password, user.passwordHash);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate access token (15 minutes)
const accessToken = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
// Generate refresh token (7 days)
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Set refresh token as httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.json({ accessToken });
});
app.listen(3000, () => console.log('Server running on port 3000'));
Protecting Routes with JWT Middleware
Create middleware to verify JWTs and protect your API endpoints:
const 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: 'Access token required' });
}
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(403).json({ error: 'Invalid token' });
}
// Attach user info to request
req.user = decoded;
next();
});
};
// Protected route example
app.get('/api/profile', authenticateToken, (req, res) => {
res.json({
userId: req.user.userId,
email: req.user.email
});
});
// Admin-only route
app.get('/api/admin', authenticateToken, (req, res) => {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
res.json({ message: 'Admin data' });
});
Client-Side JWT Storage and Usage
On the client side, store access tokens in memory (JavaScript variables) and use Axios interceptors to attach them to requests:
// auth.js - Client-side authentication module
class AuthService {
constructor() {
this.accessToken = null;
}
async login(email, password) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Include cookies
body: JSON.stringify({ email, password })
});
if (!response.ok) throw new Error('Login failed');
const { accessToken } = await response.json();
this.accessToken = accessToken;
return accessToken;
}
getAccessToken() {
return this.accessToken;
}
logout() {
this.accessToken = null;
// Call logout endpoint to clear refresh token cookie
fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include'
});
}
}
const authService = new AuthService();
// Axios interceptor to add JWT to requests
import axios from 'axios';
axios.interceptors.request.use(
(config) => {
const token = authService.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
Token Refresh Strategy
Implement automatic token refresh to maintain user sessions without requiring re-login:
// Server-side refresh endpoint
app.post('/api/auth/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, (err, decoded) => {
if (err) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
// Generate new access token
const accessToken = jwt.sign(
{ userId: decoded.userId },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken });
});
});
// Client-side auto-refresh logic
axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If access token expired, try to refresh
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const { data } = await axios.post('/api/auth/refresh', {}, {
withCredentials: true
});
authService.accessToken = data.accessToken;
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return axios(originalRequest);
} catch (refreshError) {
// Refresh failed, redirect to login
authService.logout();
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
Security Best Practices
Configure your JWT implementation with security in mind:
// .env configuration
require('dotenv').config();
const config = {
jwt: {
secret: process.env.JWT_SECRET, // Use strong random string (32+ chars)
refreshSecret: process.env.JWT_REFRESH_SECRET,
accessTokenExpiry: '15m', // Short-lived
refreshTokenExpiry: '7d'
},
cookie: {
httpOnly: true, // Prevents JavaScript access
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'strict' // CSRF protection
}
};
// Enhanced token validation
const validateToken = (token, secret) => {
try {
const decoded = jwt.verify(token, secret);
// Additional validation checks
if (!decoded.userId) {
throw new Error('Invalid token structure');
}
// Check if token was issued in the future (clock skew attack)
if (decoded.iat > Math.floor(Date.now() / 1000)) {
throw new Error('Token issued in future');
}
return decoded;
} catch (error) {
throw new Error(`Token validation failed: ${error.message}`);
}
};
Never store sensitive data in JWT payloads—they’re base64 encoded, not encrypted. Always use HTTPS in production to prevent token interception. Rotate your JWT secrets periodically and implement token blacklisting for critical applications.
Common Pitfalls and Debugging
Handle common JWT issues with proper error handling and debugging utilities:
// Comprehensive error handling
app.use((err, req, res, next) => {
console.error('Error:', err);
if (err.name === 'JsonWebTokenError') {
return res.status(403).json({ error: 'Invalid token format' });
}
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'Token expired',
expiredAt: err.expiredAt
});
}
res.status(500).json({ error: 'Internal server error' });
});
// Debugging utility
const debugToken = (token) => {
try {
const decoded = jwt.decode(token, { complete: true });
console.log('Token Debug Info:', {
header: decoded.header,
payload: decoded.payload,
isExpired: decoded.payload.exp < Date.now() / 1000,
expiresAt: new Date(decoded.payload.exp * 1000),
issuedAt: new Date(decoded.payload.iat * 1000)
});
} catch (error) {
console.error('Token decode error:', error.message);
}
};
// CORS configuration for Authorization headers
const cors = require('cors');
app.use(cors({
origin: process.env.CLIENT_URL,
credentials: true,
exposedHeaders: ['Authorization']
}));
JWT authentication provides a scalable, stateless solution for modern applications. Implement the dual-token strategy, store tokens securely, and always validate tokens server-side. With these practices, you’ll build a robust authentication system that scales effortlessly.