The Challenge of Token Expiration
JWT tokens are designed to expire after a certain period—this is a security feature, not a limitation. However, this creates a challenge: how do we maintain a persistent user session without requiring frequent re-logins?
The Token Expiration Dilemma
Consider this real-world scenario:
- Short-lived tokens (5-15 minutes): More secure but frustrate users with frequent re-authentication
- Long-lived tokens (days/weeks): Better user experience but create a significant security risk if stolen
This is analogous to a security badge at a high-security facility. A badge that works for months without renewal is convenient but risky if stolen, while a badge that expires every hour is secure but impractical.
Refresh token strategies solve this dilemma by combining:
- Short-lived access tokens for security (typically 15-60 minutes)
- Longer-lived refresh tokens for maintaining the session (days or weeks)
- A mechanism to securely obtain new access tokens when they expire
Refresh Token Pattern Overview
Real-World Analogy
The refresh token pattern works similar to hotel key cards and room safes:
- The access token is like your hotel room key card that expires daily
- The refresh token is like the passport you store in your room safe
- When your key card expires, you show your passport (refresh token) at the front desk to get a new key card without having to check in again
- If someone steals your key card, they only have access for a limited time
- If your passport is stolen, you can report it, and the hotel can invalidate it immediately
Basic Refresh Token Implementation
Let's review the core implementation of the refresh token pattern:
Basic Server-Side Refresh Endpoint
// routes/authRoutes.js
router.post('/refresh-token', async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({
success: false,
message: 'Refresh token is required'
});
}
// Verify the refresh token
let payload;
try {
// Verify signature and expiration
payload = jwt.verify(refreshToken, process.env.JWT_SECRET);
} catch (err) {
return res.status(403).json({
success: false,
message: 'Invalid refresh token'
});
}
// Check if token exists in database
const storedToken = await Token.findOne({ token: refreshToken });
if (!storedToken) {
return res.status(403).json({
success: false,
message: 'Refresh token not found or revoked'
});
}
// Create new access token
const accessToken = jwt.sign(
{ sub: payload.sub },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_ACCESS_EXPIRATION || '15m' }
);
// Return new access token
res.json({
success: true,
accessToken
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Error refreshing token',
error: error.message
});
}
});
Client-Side Refresh Implementation
// Client-side refresh logic using axios
import axios from 'axios';
const API_URL = 'http://localhost:3000/api';
// Axios instance
const api = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json'
}
});
// Add request interceptor to add auth header
api.interceptors.request.use(
(config) => {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
config.headers['Authorization'] = `Bearer ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Add response interceptor to handle token expiration
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If error is not 401 or request has already been retried, reject
if (error.response.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
originalRequest._retry = true;
try {
// Get refresh token from storage
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
// No refresh token, redirect to login
window.location.href = '/login';
return Promise.reject(error);
}
// Request new access token
const response = await axios.post(`${API_URL}/auth/refresh-token`, {
refreshToken
});
const { accessToken } = response.data;
// Store the new access token
localStorage.setItem('accessToken', accessToken);
// Update auth header
originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;
// Retry the original request
return axios(originalRequest);
} catch (refreshError) {
// Refresh token is invalid, clear tokens and redirect to login
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
);
Advanced Refresh Token Strategies
Beyond the basic implementation, several advanced strategies enhance security and user experience:
Strategy 1: Refresh Token Rotation
With refresh token rotation, a new refresh token is issued each time the access token is refreshed. This limits the window of opportunity if a refresh token is stolen.
// Enhanced refresh token endpoint with rotation
router.post('/refresh-token', async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token is required' });
}
// Verify the refresh token
let payload;
try {
payload = jwt.verify(refreshToken, process.env.JWT_SECRET);
} catch (err) {
return res.status(403).json({ message: 'Invalid refresh token' });
}
// Check if token exists in database
const storedToken = await Token.findOne({ token: refreshToken });
if (!storedToken) {
return res.status(403).json({ message: 'Refresh token not found or revoked' });
}
// Create new tokens
const accessToken = jwt.sign(
{ sub: payload.sub },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_ACCESS_EXPIRATION || '15m' }
);
// Create new refresh token (rotation)
const newRefreshToken = jwt.sign(
{ sub: payload.sub },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_REFRESH_EXPIRATION || '7d' }
);
// Calculate expiry date for database
const refreshExpiry = new Date();
refreshExpiry.setDate(
refreshExpiry.getDate() +
parseInt(process.env.JWT_REFRESH_EXPIRATION.replace('d', '') || 7)
);
// Update token in database (invalidate old, store new)
await Token.findByIdAndRemove(storedToken._id);
await Token.create({
userId: payload.sub,
token: newRefreshToken,
expiryDate: refreshExpiry
});
// Return new tokens
res.json({
success: true,
accessToken,
refreshToken: newRefreshToken
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Error refreshing token',
error: error.message
});
}
});
Benefits of Token Rotation
- Limited exposure window: A stolen refresh token is only valid until the next refresh operation
- Automatic invalidation: Previous tokens are automatically invalidated, helping prevent replay attacks
- Detection capabilities: If an attacker uses a stolen token after the legitimate user has refreshed, the server can detect this suspicious activity
Strategy 2: Refresh Token Family
Refresh token families track the lineage of rotated tokens, enabling detection of token theft and automatic invalidation of compromised token chains.
// Enhanced Token model with family tracking
const tokenSchema = new mongoose.Schema({
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
token: {
type: String,
required: true
},
expiryDate: {
type: Date,
required: true
},
family: {
type: String,
required: true
},
generation: {
type: Number,
required: true,
default: 0
},
used: {
type: Boolean,
default: false
},
createdAt: {
type: Date,
default: Date.now,
expires: '30d' // Document TTL
}
});
// Enhanced refresh endpoint with family tracking
router.post('/refresh-token', async (req, res) => {
try {
const { refreshToken } = req.body;
// ... verify JWT signature as before ...
// Get stored token
const storedToken = await Token.findOne({ token: refreshToken });
if (!storedToken) {
return res.status(403).json({ message: 'Refresh token not found' });
}
// Check if token has been used (potential token theft)
if (storedToken.used) {
// Potential token theft detected! Invalidate all tokens in this family
await Token.deleteMany({ family: storedToken.family });
return res.status(403).json({
success: false,
message: 'Token theft detected! Please login again'
});
}
// Mark current token as used
storedToken.used = true;
await storedToken.save();
// Generate new tokens
const accessToken = jwt.sign(
{ sub: payload.sub },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_ACCESS_EXPIRATION || '15m' }
);
const newRefreshToken = jwt.sign(
{ sub: payload.sub },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_REFRESH_EXPIRATION || '7d' }
);
// Store new refresh token with same family but next generation
const refreshExpiry = new Date();
refreshExpiry.setDate(
refreshExpiry.getDate() +
parseInt(process.env.JWT_REFRESH_EXPIRATION.replace('d', '') || 7)
);
await Token.create({
userId: storedToken.userId,
token: newRefreshToken,
expiryDate: refreshExpiry,
family: storedToken.family,
generation: storedToken.generation + 1
});
res.json({
success: true,
accessToken,
refreshToken: newRefreshToken
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Error refreshing token',
error: error.message
});
}
});
How Token Families Detect Theft
Consider this scenario:
- User logs in and receives refresh token A (Family: F123, Generation: 0)
- Attacker somehow steals refresh token A
- User refreshes first and receives token B (Family: F123, Generation: 1)
- Token A is marked as "used" but not deleted
- Attacker tries to use stolen token A
- Server sees token A is already marked "used"
- Server invalidates ALL tokens in family F123
- Both attacker and legitimate user need to re-authenticate
This approach provides an excellent balance between security and user experience, as legitimate users will only be logged out if token theft is detected.
Strategy 3: Sliding Window Refresh
With sliding window refresh, the refresh token's expiration is extended each time it's used, as long as it's used within a valid timeframe.
// Sliding window refresh implementation
router.post('/refresh-token', async (req, res) => {
try {
const { refreshToken } = req.body;
// ... verify JWT signature as before ...
// Get stored token
const storedToken = await Token.findOne({ token: refreshToken });
if (!storedToken) {
return res.status(403).json({ message: 'Refresh token not found' });
}
// Generate new access token
const accessToken = jwt.sign(
{ sub: payload.sub },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_ACCESS_EXPIRATION || '15m' }
);
// Extend refresh token expiration (sliding window)
const newExpiryDate = new Date();
newExpiryDate.setDate(
newExpiryDate.getDate() +
parseInt(process.env.JWT_REFRESH_EXPIRATION.replace('d', '') || 7)
);
// Only extend if new expiry would be later than current
if (newExpiryDate > storedToken.expiryDate) {
storedToken.expiryDate = newExpiryDate;
await storedToken.save();
}
res.json({
success: true,
accessToken
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Error refreshing token',
error: error.message
});
}
});
Benefits of Sliding Window Refresh
The sliding window approach provides:
- Extended sessions for active users: Users who regularly use your app stay logged in longer
- Automatic timeout for inactive users: If users don't use the app for the refresh token lifetime, they're logged out
- Improved user experience: Reduces the need for re-authentication for regular users
This is similar to how video streaming services keep you logged in as long as you watch something every few days, but log you out after extended inactivity.
Refresh Token Storage
Proper storage of refresh tokens is crucial for security:
Server-side Storage
- Database storage: Most common, allows for easy revocation and tracking
- Redis/in-memory storage: Faster performance, good for high-traffic applications
- Security considerations: Store token hashes rather than raw tokens for additional security
// Enhanced token model with hashed token storage
const crypto = require('crypto');
const tokenSchema = new mongoose.Schema({
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
// Store hash of token instead of raw token
tokenHash: {
type: String,
required: true
},
expiryDate: {
type: Date,
required: true
},
createdAt: {
type: Date,
default: Date.now,
expires: '30d'
}
});
// Helper function to hash tokens
function hashToken(token) {
return crypto
.createHash('sha256')
.update(token)
.digest('hex');
}
// Modified token creation
async function createRefreshToken(userId, token) {
const expiryDate = new Date();
expiryDate.setDate(
expiryDate.getDate() +
parseInt(process.env.JWT_REFRESH_EXPIRATION.replace('d', '') || 7)
);
await Token.create({
userId,
tokenHash: hashToken(token),
expiryDate
});
}
// Modified token lookup
async function findRefreshToken(token) {
return Token.findOne({ tokenHash: hashToken(token) });
}
Client-side Storage
| Storage Method | Security Level | Best For | Risks |
|---|---|---|---|
| HttpOnly Cookie | High | Refresh tokens | CSRF attacks (mitigable) |
| localStorage | Low | Development only | XSS attacks |
| In-memory (JavaScript variable) | Medium | Access tokens | Lost on page refresh |
Best Practice: Cookie-based Refresh Tokens
The most secure approach for web applications is:
- Access token: Stored in memory (JavaScript variable or state)
- Refresh token: Stored in HttpOnly, Secure, SameSite=Strict cookie
This combination provides excellent security since:
- Access tokens are short-lived, minimizing damage if stolen
- Refresh tokens in HttpOnly cookies cannot be accessed by JavaScript (XSS protection)
- SameSite=Strict cookies provides CSRF protection
// Setting refresh token in HttpOnly cookie
router.post('/login', async (req, res) => {
try {
// ... authentication logic ...
// Generate tokens
const accessToken = jwt.sign(
{ sub: user._id },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ sub: user._id },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
// Save refresh token in database (typically as a hash)
await createRefreshToken(user._id, refreshToken);
// Set refresh token as HttpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in milliseconds
path: '/api/auth/refresh-token' // Restrict cookie to refresh endpoint
});
// Return access token in response body
res.json({
success: true,
accessToken,
user: {
id: user._id,
email: user.email,
name: user.name
}
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Login failed',
error: error.message
});
}
});
// Refresh endpoint that uses the cookie
router.post('/refresh-token', async (req, res) => {
try {
// Get refresh token from cookie instead of request body
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token required' });
}
// ... verify and validate token as before ...
// Generate new access token
const accessToken = jwt.sign(
{ sub: payload.sub },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
// Return new access token
res.json({
success: true,
accessToken
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Token refresh failed',
error: error.message
});
}
});
Cross-Site Request Forgery (CSRF) Protection
When using cookies for refresh tokens, CSRF protection is essential:
// Install CSRF protection
npm install csurf
// server.js or routes file
const csrf = require('csurf');
const cookieParser = require('cookie-parser');
// Setup CSRF protection
const csrfProtection = csrf({
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
}
});
// Apply middleware
app.use(cookieParser());
// CSRF protection for token refresh
router.post('/refresh-token', csrfProtection, async (req, res) => {
try {
// Get token from cookie
const refreshToken = req.cookies.refreshToken;
// ... rest of the refresh logic ...
res.json({
success: true,
accessToken,
// Include CSRF token in response
csrfToken: req.csrfToken()
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Token refresh failed',
error: error.message
});
}
});
// Client-side implementation
// Include CSRF token in subsequent requests
fetch('/api/resource', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken, // From previous response
'Authorization': `Bearer ${accessToken}`
},
credentials: 'include', // Important for cookies
body: JSON.stringify(data)
});
SameSite Cookie Attribute
Modern browsers support the SameSite cookie attribute, which can significantly mitigate CSRF attacks:
- SameSite=Strict: Cookie is only sent in requests from the same site (strongest protection)
- SameSite=Lax: Cookie is sent in same-site requests and top-level navigations
- SameSite=None: Cookie is sent in all contexts (requires Secure attribute)
For refresh tokens, SameSite=Strict is recommended as they should only be used by your own application.
Mobile and Native App Considerations
Mobile and native applications have different security considerations for refresh tokens:
Secure Storage Options
- iOS: Keychain Services
- Android: EncryptedSharedPreferences, Keystore
- React Native: react-native-keychain, expo-secure-store
- Electron: electron-store with encryption
// React Native example with react-native-keychain
import * as Keychain from 'react-native-keychain';
// Storing tokens securely
const storeTokens = async (accessToken, refreshToken) => {
try {
// Store access token
await Keychain.setGenericPassword(
'accessToken',
accessToken,
{
service: 'accessToken',
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED
}
);
// Store refresh token
await Keychain.setGenericPassword(
'refreshToken',
refreshToken,
{
service: 'refreshToken',
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED
}
);
return true;
} catch (error) {
console.error('Error storing tokens:', error);
return false;
}
};
// Retrieving tokens
const getAccessToken = async () => {
try {
const credentials = await Keychain.getGenericPassword({
service: 'accessToken'
});
if (credentials) {
return credentials.password;
}
return null;
} catch (error) {
console.error('Error getting access token:', error);
return null;
}
};
const getRefreshToken = async () => {
try {
const credentials = await Keychain.getGenericPassword({
service: 'refreshToken'
});
if (credentials) {
return credentials.password;
}
return null;
} catch (error) {
console.error('Error getting refresh token:', error);
return null;
}
};
Device Authentication
For enhanced security in mobile apps, you can bind refresh tokens to specific devices:
// Enhanced token model with device information
const tokenSchema = new mongoose.Schema({
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
tokenHash: {
type: String,
required: true
},
expiryDate: {
type: Date,
required: true
},
// Device information
deviceInfo: {
deviceId: String,
deviceName: String,
platform: String,
appVersion: String,
lastIp: String,
lastUsed: Date
},
createdAt: {
type: Date,
default: Date.now,
expires: '30d'
}
});
// Server endpoint with device binding
router.post('/login', async (req, res) => {
try {
// ... authentication logic ...
// Get device information from request
const { deviceId, deviceName, platform, appVersion } = req.body;
// Generate tokens
const accessToken = jwt.sign({ /* ... */ }, process.env.JWT_SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign({ /* ... */ }, process.env.JWT_SECRET, { expiresIn: '30d' });
// Store with device information
await Token.create({
userId: user._id,
tokenHash: hashToken(refreshToken),
expiryDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
deviceInfo: {
deviceId,
deviceName,
platform,
appVersion,
lastIp: req.ip,
lastUsed: new Date()
}
});
res.json({
success: true,
accessToken,
refreshToken
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Login failed',
error: error.message
});
}
});
Testing Refresh Token Implementations
Proper testing is essential for ensuring your refresh token implementation works correctly:
// jest test example
const request = require('supertest');
const jwt = require('jsonwebtoken');
const app = require('../app');
const User = require('../models/User');
const Token = require('../models/Token');
const { connect, disconnect } = require('../config/database');
beforeAll(async () => {
await connect();
});
afterAll(async () => {
await disconnect();
});
describe('Refresh Token Flow', () => {
let accessToken, refreshToken, userId;
beforeEach(async () => {
// Create test user
await User.deleteMany({});
await Token.deleteMany({});
const user = await User.create({
email: 'test@example.com',
password: 'password123',
username: 'testuser'
});
userId = user._id;
// Login to get tokens
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'test@example.com', password: 'password123' });
accessToken = res.body.accessToken;
refreshToken = res.body.refreshToken;
});
test('Should access protected route with valid access token', async () => {
const res = await request(app)
.get('/api/users/profile')
.set('Authorization', `Bearer ${accessToken}`);
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
});
test('Should refresh token when access token expires', async () => {
// Create expired access token
const expiredToken = jwt.sign(
{ sub: userId },
process.env.JWT_SECRET,
{ expiresIn: '0s' } // Immediately expired
);
// Try to access protected route with expired token
const failedRes = await request(app)
.get('/api/users/profile')
.set('Authorization', `Bearer ${expiredToken}`);
expect(failedRes.statusCode).toBe(401);
// Refresh the token
const refreshRes = await request(app)
.post('/api/auth/refresh-token')
.send({ refreshToken });
expect(refreshRes.statusCode).toBe(200);
expect(refreshRes.body.accessToken).toBeDefined();
// Access protected route with new token
const newAccessToken = refreshRes.body.accessToken;
const successRes = await request(app)
.get('/api/users/profile')
.set('Authorization', `Bearer ${newAccessToken}`);
expect(successRes.statusCode).toBe(200);
});
test('Should fail with invalid refresh token', async () => {
const res = await request(app)
.post('/api/auth/refresh-token')
.send({ refreshToken: 'invalid-token' });
expect(res.statusCode).toBe(403);
});
test('Should detect token reuse in family-based implementation', async () => {
// Refresh once to get a new token
const refresh1 = await request(app)
.post('/api/auth/refresh-token')
.send({ refreshToken });
const newRefreshToken = refresh1.body.refreshToken;
// Try to use the original token again (simulating theft)
const reusedTokenRes = await request(app)
.post('/api/auth/refresh-token')
.send({ refreshToken });
expect(reusedTokenRes.statusCode).toBe(403);
expect(reusedTokenRes.body.message).toContain('token theft');
// Verify that all tokens in family are invalidated
const invalidatedFamilyRes = await request(app)
.post('/api/auth/refresh-token')
.send({ refreshToken: newRefreshToken });
expect(invalidatedFamilyRes.statusCode).toBe(403);
});
});
Handling Edge Cases
A robust refresh token implementation must handle various edge cases:
Common Edge Cases
- Concurrent refresh requests: Multiple tabs refreshing tokens simultaneously
- Network interruptions: Client loses connection during refresh process
- Clock skew: Server and client have different time settings
- Client-server version mismatch: Token format changes in a new API version
- Max sessions per user: Limiting the number of active sessions
Managing Multiple Active Sessions
// Example: Limiting active sessions per user
const MAX_SESSIONS_PER_USER = 5;
// During login
router.post('/login', async (req, res) => {
try {
// ... authentication logic ...
// Check number of active sessions
const activeTokenCount = await Token.countDocuments({
userId: user._id
});
// If max sessions reached, remove oldest session
if (activeTokenCount >= MAX_SESSIONS_PER_USER) {
// Find and remove oldest token
const oldestToken = await Token.findOne({
userId: user._id
}).sort({ createdAt: 1 });
if (oldestToken) {
await Token.findByIdAndRemove(oldestToken._id);
}
}
// Generate and store new token
// ... rest of login logic ...
} catch (error) {
res.status(500).json({
success: false,
message: 'Login failed',
error: error.message
});
}
});
// API endpoint to view and manage active sessions
router.get('/active-sessions', verifyToken, async (req, res) => {
try {
const sessions = await Token.find({
userId: req.userId
}).select('deviceInfo createdAt expiryDate');
res.json({
success: true,
sessions: sessions.map(session => ({
id: session._id,
device: session.deviceInfo.deviceName,
platform: session.deviceInfo.platform,
lastUsed: session.deviceInfo.lastUsed,
createdAt: session.createdAt,
expiresAt: session.expiryDate
}))
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Error retrieving sessions',
error: error.message
});
}
});
// API endpoint to revoke a specific session
router.post('/revoke-session/:sessionId', verifyToken, async (req, res) => {
try {
const { sessionId } = req.params;
// Find the session and verify it belongs to current user
const session = await Token.findOne({
_id: sessionId,
userId: req.userId
});
if (!session) {
return res.status(404).json({
success: false,
message: 'Session not found'
});
}
// Remove the session
await Token.findByIdAndRemove(sessionId);
res.json({
success: true,
message: 'Session revoked successfully'
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Error revoking session',
error: error.message
});
}
});
Production-Ready Implementation Checklist
Before deploying your refresh token strategy to production, ensure it meets these requirements:
Security Checklist
- HTTPS: Ensure all authentication endpoints use HTTPS
- Secure token storage: HttpOnly cookies or secure storage for refresh tokens
- Token expiration: Short-lived access tokens (15-60 min), reasonable refresh token expiration (1-4 weeks)
- Token rotation or families: Implement a theft detection mechanism
- Refresh token hashing: Store hashed tokens rather than raw tokens
- CSRF protection: Protect cookie-based implementations
- Device tracking: Associate tokens with device information
- Rate limiting: Limit refresh attempts to prevent brute force attacks
User Experience Checklist
- Transparent refreshes: Users shouldn't notice token refreshes
- Multi-tab support: Handle concurrent refresh requests
- Session management: Allow users to view and manage active sessions
- "Remember me" option: Vary token expiration based on user preference
- Graceful degradation: Handle refresh failures without breaking the UI
Operational Checklist
- Monitoring: Track token usage, refresh rates, and failures
- Token cleanup: Implement TTL or periodic cleanup of expired tokens
- Database indexing: Ensure token lookups are efficient
- Error handling: Handle all edge cases gracefully
- Logging: Log suspicious activities (theft detection, unusual refresh patterns)
Practice Activities
Activity 1: Implement Refresh Token Rotation
Enhance your JWT authentication system with refresh token rotation:
- Modify your refresh token endpoint to issue a new refresh token with each refresh
- Update the database schema to track token usage
- Create a system to detect and handle token reuse
- Test the implementation with multiple scenarios (normal refresh, token reuse)
Activity 2: Cookie-based Refresh Implementation
Implement a secure cookie-based refresh token system:
- Modify your server to store refresh tokens in HttpOnly cookies
- Add proper cookie settings (Secure, SameSite=Strict)
- Implement CSRF protection for cookie-based endpoints
- Test cross-site scenarios to verify CSRF protection
Activity 3: Session Management Interface
Create a user-facing session management interface:
- Create an API endpoint to list all active sessions for a user
- Implement the ability to revoke specific sessions
- Add a "Log out everywhere" feature
- Create a React component to display and manage sessions
- Include device information and last activity time for each session
Additional Resources
Summary
- Refresh token strategies balance security and user experience by combining short-lived access tokens with longer-lived refresh tokens
- Advanced techniques like token rotation, token families, and sliding windows enhance security
- Secure storage is crucial: HttpOnly cookies for web apps, secure storage APIs for mobile
- CSRF protection is essential when using cookie-based refresh tokens
- A complete implementation should handle edge cases like concurrent requests, token reuse, and multiple devices
- Session management features improve user experience and security
In our next session, we'll explore session-based authentication as an alternative authentication method.