Skip to main content

Refresh Tokens: Extending React Session Safely

A refresh token is a long-lived, opaque credential that the server issues alongside a short-lived access token. When the access token expires, the React app exchanges the refresh token for a new access token without requiring the user to log in again. This pattern limits the damage if an access token is stolen (exposure window is short) while preserving session continuity. Refresh tokens are revocable: the server can invalidate all of a user's sessions by blocking their refresh tokens, enabling true logout and multi-device control.

The Access Token vs. Refresh Token Pattern

Why Two Tokens?

An access token (valid 5–15 minutes) is sent with every API request. If stolen, it is exposed for a limited time. A refresh token (valid 7–30 days) is used only to request new access tokens and can be invalidated by the server. This separation is a core security principle: the high-frequency token is low-risk (short-lived); the long-lived token is low-frequency and revocable.

According to a 2025 Okta benchmark, 64% of enterprises use multi-factor refresh-token validation (e.g., rotating refresh tokens or device fingerprinting) to prevent token replay attacks. The pattern is industry standard.

Token Lifecycle

  1. User logs in; backend issues an access token (15 min) and a refresh token (7 days)
  2. React includes the access token in API headers for 15 minutes
  3. Access token expires; React detects it (401 response) and calls the refresh endpoint
  4. Refresh endpoint validates the refresh token and issues a new access token
  5. React retries the original request with the new access token
  6. User logs out; backend revokes the refresh token (no more new access tokens)

Implementing Refresh Tokens in the Backend

Issuing Both Tokens on Login

// backend/routes/auth.js (Node.js/Express)
const jwt = require('jsonwebtoken');
const crypto = require('crypto');

const SECRET = process.env.JWT_SECRET;
const REFRESH_SECRET = process.env.REFRESH_SECRET;

// In-memory store (in production, use a database)
const refreshTokens = new Map();

app.post('/api/login', express.json(), async (req, res) => {
const { email, password } = req.body;

// Validate credentials (pseudocode)
const user = await User.findByEmail(email);
if (!user || !await user.comparePassword(password)) {
return res.status(401).json({ error: 'Invalid credentials' });
}

// ✅ Create access token (short-lived)
const accessToken = jwt.sign(
{
sub: user.id,
email: user.email,
type: 'access'
},
SECRET,
{ expiresIn: '15m', issuer: 'auth.example.com' }
);

// ✅ Create refresh token (long-lived, opaque)
const refreshTokenId = crypto.randomBytes(32).toString('hex');
const refreshToken = jwt.sign(
{
sub: user.id,
jti: refreshTokenId, // Unique token ID for revocation
type: 'refresh'
},
REFRESH_SECRET,
{ expiresIn: '7d', issuer: 'auth.example.com' }
);

// Store refresh token ID in database for revocation
await RefreshToken.create({
userId: user.id,
jti: refreshTokenId,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});

// 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,
path: '/api/refresh'
});

// Return access token in response (or also in a cookie, depending on architecture)
return res.json({
accessToken, // Can also be in a cookie if preferred
expiresIn: 15 * 60,
user: { id: user.id, email: user.email }
});
});

Refresh Endpoint: Exchanging Refresh for Access

app.post('/api/refresh', express.json(), async (req, res) => {
// Refresh token is in httpOnly cookie (sent automatically)
const refreshToken = req.cookies.refreshToken;

if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token missing' });
}

try {
// Verify refresh token signature
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);

// Check if refresh token is revoked in database
const tokenRecord = await RefreshToken.findOne({ jti: decoded.jti });

if (!tokenRecord || tokenRecord.revokedAt) {
return res.status(401).json({ error: 'Refresh token revoked' });
}

// ✅ Issue new access token
const newAccessToken = jwt.sign(
{
sub: decoded.sub,
email: decoded.email,
type: 'access'
},
SECRET,
{ expiresIn: '15m' }
);

return res.json({
accessToken: newAccessToken,
expiresIn: 15 * 60
});

} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Refresh token expired' });
}
return res.status(401).json({ error: 'Invalid refresh token' });
}
});

React: Detecting and Handling Token Expiration

Axios Interceptor for Automatic Refresh

// apiClient.js
import axios from 'axios';

const API_BASE_URL = 'https://api.example.com';

const apiClient = axios.create({
baseURL: API_BASE_URL,
withCredentials: true // Include cookies
});

let isRefreshing = false;
let failedQueue = [];

const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};

// Response interceptor to handle 401 and refresh token
apiClient.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;

if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// Queue the request while refreshing
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(token => {
originalRequest.headers['Authorization'] = `Bearer ${token}`;
return apiClient(originalRequest);
});
}

originalRequest._retry = true;
isRefreshing = true;

try {
// Call refresh endpoint
const response = await axios.post(
`${API_BASE_URL}/api/refresh`,
{},
{ withCredentials: true }
);

const { accessToken } = response.data;

// Update headers for future requests
apiClient.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;

processQueue(null, accessToken);

// Retry original request
return apiClient(originalRequest);

} catch (refreshError) {
processQueue(refreshError, null);
// Redirect to login
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}

return Promise.reject(error);
}
);

export default apiClient;

Fetch API Alternative (Without Axios)

// authFetch.js (wrapper for fetch with token management)
let currentAccessToken = null;

export async function authFetch(url, options = {}) {
const headers = {
...options.headers,
'Content-Type': 'application/json'
};

if (currentAccessToken) {
headers['Authorization'] = `Bearer ${currentAccessToken}`;
}

let response = await fetch(url, {
...options,
headers,
credentials: 'include'
});

// If 401, try to refresh the token
if (response.status === 401) {
try {
const refreshResponse = await fetch('https://api.example.com/api/refresh', {
method: 'POST',
credentials: 'include'
});

if (!refreshResponse.ok) {
throw new Error('Refresh failed');
}

const data = await refreshResponse.json();
currentAccessToken = data.accessToken;

// Retry the original request with new token
headers['Authorization'] = `Bearer ${currentAccessToken}`;
response = await fetch(url, {
...options,
headers,
credentials: 'include'
});

} catch (err) {
console.error('Token refresh failed:', err);
window.location.href = '/login';
throw err;
}
}

return response;
}

// Usage
const response = await authFetch('/api/me');
const user = await response.json();

Silent Renewal: Proactive Token Refresh

Instead of waiting for a 401 error, you can refresh tokens before they expire:

// useTokenRefresh.js (Custom React hook)
import { useEffect } from 'react';

export function useTokenRefresh(refreshInterval = 12 * 60 * 1000) {
// Refresh token 3 minutes before expiration (15-min token, refresh at 12 min)

useEffect(() => {
const interval = setInterval(async () => {
try {
const response = await fetch('https://api.example.com/api/refresh', {
method: 'POST',
credentials: 'include'
});

if (response.ok) {
const data = await response.json();
// Store access token (if not in cookie)
localStorage.setItem('accessToken', data.accessToken);
} else {
// Token refresh failed; user needs to log in again
window.location.href = '/login';
}
} catch (err) {
console.error('Silent refresh failed:', err);
}
}, refreshInterval);

return () => clearInterval(interval);
}, [refreshInterval]);
}

// Use in your main app component
function App() {
useTokenRefresh();
// ... rest of app
}

Revoking Refresh Tokens (Logout)

On logout, invalidate all of the user's refresh tokens:

// backend/routes/auth.js
app.post('/api/logout', async (req, res) => {
const refreshToken = req.cookies.refreshToken;

if (refreshToken) {
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);

// Mark refresh token as revoked
await RefreshToken.updateOne(
{ jti: decoded.jti },
{ revokedAt: new Date() }
);

} catch (err) {
// Token invalid or expired; that is okay
}
}

// Clear cookie on client
res.clearCookie('refreshToken');

return res.json({ message: 'Logged out' });
});

// To log out from all devices:
app.post('/api/logout-all-devices', async (req, res) => {
const userId = req.user.id; // From verified access token

// Revoke all refresh tokens for this user
await RefreshToken.updateMany(
{ userId },
{ revokedAt: new Date() }
);

res.clearCookie('refreshToken');
return res.json({ message: 'Logged out from all devices' });
});

Comparing Refresh Token Strategies

StrategySecurityComplexityUser Experience
Single long-lived tokenLow; full exposure if stolenLowGood; no refresh needed
Access + Refresh tokensHigh; short exposure windowMediumGood; transparent refresh
Rotating refresh tokensVery high; refresh revoked after useHighGood; requires careful sync
Sliding sessionsMedium; window-based expiryLowGood; implicit renewal

Key Takeaways

  • Access tokens are short-lived (5–15 minutes) and included in every API call; refresh tokens are long-lived (days/weeks) and used only to get new access tokens
  • Refresh tokens must be stored securely (httpOnly cookies) and are revocable; the server can block all sessions for a user
  • Implement a refresh interceptor in your HTTP client (Axios or fetch) to transparently refresh tokens on 401
  • Store the refresh token ID (jti claim) in a database to enable revocation; always check if a token is revoked before issuing a new access token
  • Silent renewal (proactive refresh before expiration) improves UX; refresh 1–3 minutes before expiration
  • Logout must revoke the refresh token; "log out from all devices" revokes all of the user's refresh tokens
  • Use a queue to batch requests during token refresh; do not send multiple simultaneous refresh requests
  • Test token refresh with simulated expiration to verify the flow works end-to-end

Frequently Asked Questions

What happens if someone steals my refresh token?

The attacker can request new access tokens indefinitely (until the refresh token expires or is revoked). The server can mitigate by: revoking the token immediately (via a logout endpoint), using rotating refresh tokens (each refresh issues a new refresh token), or binding tokens to device fingerprints. The exposure window is much longer than a stolen access token, which is why refresh tokens must be protected (httpOnly cookies).

Can I use the same refresh token multiple times?

Yes, in a basic implementation. For higher security, use rotating refresh tokens: each time you exchange a refresh token for an access token, the server issues a new refresh token and invalidates the old one. This prevents replay attacks if a token is stolen in transit.

Should I store the refresh token in a database?

Yes. Store the token ID (jti), user ID, issue/expiration times, and a revokedAt timestamp. This allows the server to revoke tokens without storing a blacklist and to implement "log out from all devices."

What if the user closes the browser and the refresh token expires?

The user must log in again. Refresh token expiration is intentional; it forces re-authentication periodically. Set the expiration based on security requirements: e.g., 7 days for a low-risk app, 1 day for a banking app.

Can I use refresh tokens with CORS?

Yes. Refresh tokens are often stored in httpOnly cookies, which are sent automatically. If the refresh endpoint is on a different domain, you need CORS with credentials: true (covered in the CORS article). The refresh token itself never leaves the cookie; it is invisible to JavaScript.

Further Reading