JWT Refresh Token: How to Implement Secure Token Rotation
Learn how to implement JWT refresh tokens and secure token rotation with access token refresh patterns. Complete implementation guide.
In This Article
Why Refresh Tokens Matter
Access tokens are designed to be short-lived — typically lasting 15 minutes to one hour. This limits the damage if a token is stolen. But short-lived tokens create a usability problem: users would need to re-authenticate every time their token expires. That's where refresh tokens come in.
A refresh token is a long-lived credential (typically 7–30 days) that is used to obtain new access tokens without requiring the user to log in again. When the access token expires, the client sends the refresh token to the authorization server, which issues a new access token. This pattern is formalized in the OAuth 2.0 RFC 6749 specification.
If you're new to JWT structure, start with our What is JWT guide to understand the fundamentals before diving into refresh token flows.
Access Token vs Refresh Token
Understanding the distinction between these two token types is critical:
| Property | Access Token | Refresh Token |
|----------|-------------|---------------|
| Lifespan | 15 min – 1 hour | 7 – 30 days |
| Purpose | Access protected resources | Obtain new access tokens |
| Storage | Memory or short-lived cookie | Secure HttpOnly cookie |
| Sent to | Resource servers | Authorization server only |
| Scope | Limited permissions | Can request same or lesser scope |
The access token is like a hotel room key — it gives you access for a short period. The refresh token is like your reservation confirmation — it lets you get a new key when the old one expires.
How Token Rotation Works
Token rotation is a security enhancement where a new refresh token is issued every time the old one is used. The previous refresh token is immediately invalidated. This limits the window of opportunity for an attacker who steals a refresh token.
The Rotation Flow
- User authenticates → receives access token + refresh token (RT1)
- Access token expires → client sends RT1 to auth server
- Auth server validates RT1, issues new access token + new refresh token (RT2)
- RT1 is invalidated in the database
- Next refresh → client sends RT2, receives RT3, RT2 is invalidated
- If an attacker tries to use RT1 after rotation, the server detects reuse and revokes the entire token family
This approach is recommended by the OWASP token storage guide because it provides breach detection: if a stolen refresh token is used after the legitimate client has already rotated it, the server knows a breach has occurred.
Server-Side Implementation with Node.js and Express
Here's a complete implementation of refresh token rotation using Express, jsonwebtoken, and a simple in-memory store (in production, use a database like PostgreSQL or Redis):
const express = require('express');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const app = express();
app.use(express.json());
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET || 'access-secret-key';
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || 'refresh-secret-key';
// In production, use a database instead of this Map
const refreshTokenStore = new Map(); // tokenId -> { userId, family, version, revoked }
function generateAccessToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m', algorithm: 'HS256' }
);
}
function generateRefreshToken(userId) {
const tokenId = crypto.randomUUID();
const family = crypto.randomUUID();
const token = jwt.sign(
{ sub: userId, jti: tokenId, family, type: 'refresh' },
REFRESH_TOKEN_SECRET,
{ expiresIn: '7d', algorithm: 'HS256' }
);
refreshTokenStore.set(tokenId, { userId, family, version: 1, revoked: false });
return { token, tokenId, family };
}
// Login endpoint
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
// Validate credentials against your database
const user = await authenticateUser(email, password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const accessToken = generateAccessToken(user);
const refreshTokenData = generateRefreshToken(user.id);
res.json({
accessToken,
refreshToken: refreshTokenData.token,
expiresIn: 900 // 15 minutes in seconds
});
});
// Refresh endpoint with token rotation
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) return res.status(400).json({ error: 'Refresh token required' });
try {
const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
const stored = refreshTokenStore.get(decoded.jti);
if (!stored || stored.revoked) {
// Token reuse detected — possible breach!
// Revoke entire token family
for (const [id, entry] of refreshTokenStore) {
if (entry.family === decoded.family) entry.revoked = true;
}
return res.status(401).json({ error: 'Token reuse detected, session revoked' });
}
// Mark current token as revoked (rotation)
stored.revoked = true;
// Issue new token pair
const newAccessToken = generateAccessToken({ id: decoded.sub, email: '', role: 'user' });
const newRefreshData = generateRefreshToken(decoded.sub);
res.json({
accessToken: newAccessToken,
refreshToken: newRefreshData.token,
expiresIn: 900
});
} catch (err) {
res.status(401).json({ error: 'Invalid or expired refresh token' });
}
});
app.listen(3000, () => console.log('Auth server running on port 3000'));
This implementation includes token family tracking for breach detection and automatic revocation of the entire family if token reuse is detected.
Client-Side Auto-Refresh Logic
On the frontend, you need to intercept 401 responses and automatically refresh the access token. Here's a robust implementation using axios:
import axios from 'axios';
const api = axios.create({ baseURL: 'https://api.example.com' });
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach((prom) => {
if (error) prom.reject(error);
else prom.resolve(token);
});
failedQueue = [];
};
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
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 refreshToken = getRefreshToken(); // from secure storage
const { data } = await axios.post('https://api.example.com/auth/refresh', { refreshToken });
storeTokens(data.accessToken, data.refreshToken);
processQueue(null, data.accessToken);
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
logoutUser(); // redirect to login
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
This pattern queues concurrent requests that fail with 401, refreshes the token once, then retries all queued requests with the new token.
Secure Storage Strategies
Where you store refresh tokens has major security implications:
HttpOnly Cookies (Recommended)
The most secure option for web applications. The refresh token is stored in an HttpOnly, Secure, SameSite cookie that JavaScript cannot access:
// Server-side cookie setting
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/auth/refresh' // restrict to refresh endpoint
});
Mobile Secure Storage
For mobile apps, use platform-specific secure storage:
- iOS: Keychain Services
- Android: EncryptedSharedPreferences or Keystore
What to Avoid
Never store refresh tokens in localStorage or sessionStorage — they are accessible to any JavaScript running on the page, making them vulnerable to XSS attacks. For more on this, see our JWT security best practices.
Common Pitfalls and How to Avoid Them
1. Not Implementing Token Rotation
Without rotation, a stolen refresh token can be used indefinitely. Always issue a new refresh token with each use and invalidate the old one.
2. Sharing Secrets Between Access and Refresh Tokens
Use separate secrets for access and refresh tokens. If an attacker compromises one, they shouldn't be able to forge the other.
3. Ignoring Clock Skew
Differences in system clocks between client and server can cause valid tokens to appear expired. Add a small clock skew tolerance (30–60 seconds) when validating expiration.
4. Not Detecting Token Reuse
If a refresh token that has already been rotated is presented again, it likely means an attacker is using a stolen token. Revoke the entire token family immediately.
Conclusion
Refresh tokens bridge the gap between security (short-lived access tokens) and usability (not forcing users to re-authenticate). By implementing token rotation, secure storage, and reuse detection, you build a robust authentication system that is both user-friendly and secure. Use our JWT decoder tool to inspect your tokens and verify they contain the correct claims.
Need to decode a JWT token? Try our free JWT decoder tool — no sign-up required, runs entirely in your browser.