Silent Refresh Using HTTP-Only Cookies: Secure Token Management

Silent refresh is a technique that keeps users logged in without interrupting their experience. Instead of making users log in again when their access token
expires, we can automatically get new tokens in the background. This article shows how to do this securely using HTTP-only cookies
.
What is Silent Refresh?
Silent refresh
means updating expired tokens without the user knowing. When your access token
(usually valid for 15-30 minutes) expires, instead of redirecting to login, you use a refresh token
to get new tokens automatically.
Why Use HTTP-Only Cookies?
HTTP-only cookies
are special cookies that JavaScript cannot access. This protects against XSS (Cross-Site Scripting)
attacks where malicious scripts try to steal your tokens.
Benefits:
- Tokens stored safely in cookies
- JavaScript cannot read them (prevents XSS)
- Browser handles cookie security automatically
- Tokens included in requests automatically
How Silent Refresh Works
1. User logs in → Get access token + refresh token
2. Store refresh token in HTTP-only cookie
3. Access token expires → Browser gets 401 error
4. Automatically call refresh endpoint with cookie
5. Get new tokens → Continue user's request
6. User never knows tokens were refreshed
Implementation Steps
1. Backend Setup (Node.js/Express)
First, set up your auth endpoints to use HTTP-only cookies:
// Login endpoint
app.post('/auth/login', async (req, res) => {
// Validate user credentials
const user = await validateUser(req.body.email, req.body.password)
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' })
}
// Create tokens
const accessToken = jwt.sign(
{ userId: user.id, email: user.email },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }, // Short-lived
)
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }, // Long-lived
)
// Store refresh token in HTTP-only cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // Cannot be accessed by JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
})
// Return access token to client
res.json({
accessToken,
user: { id: user.id, email: user.email },
})
})
2. Refresh Token Endpoint
Create an endpoint that uses the HTTP-only cookie to refresh tokens:
// Refresh endpoint
app.post('/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' })
}
try {
// Verify refresh token
const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET)
// Get user from database
const user = await User.findById(payload.userId)
if (!user) {
return res.status(401).json({ error: 'User not found' })
}
// Create new access token
const newAccessToken = jwt.sign(
{ userId: user.id, email: user.email },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' },
)
// Optionally create new refresh token (token rotation)
const newRefreshToken = jwt.sign(
{ userId: user.id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' },
)
// Update HTTP-only cookie with new refresh token
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
})
// Return new access token
res.json({
accessToken: newAccessToken,
user: { id: user.id, email: user.email },
})
} catch (error) {
// Refresh token is invalid or expired
res.clearCookie('refreshToken')
return res.status(401).json({ error: 'Invalid refresh token' })
}
})
3. Frontend Implementation (React)
Create an HTTP interceptor
that handles token refresh automatically:
// api.js - Axios setup with interceptors
import axios from 'axios'
const api = axios.create({
baseURL: 'http://localhost:3001/api',
withCredentials: true, // Important: sends cookies with requests
})
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 for handling token refresh
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
// Check if error is 401 and we haven't already tried to refresh
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// If already refreshing, queue this request
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`
return api(originalRequest)
})
.catch((err) => {
return Promise.reject(err)
})
}
originalRequest._retry = true
isRefreshing = true
try {
// Try to refresh token
const response = await api.post('/auth/refresh')
const { accessToken } = response.data
// Update token in memory
setAccessToken(accessToken)
// Process queued requests
processQueue(null, accessToken)
// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${accessToken}`
return api(originalRequest)
} catch (refreshError) {
// Refresh failed, logout user
processQueue(refreshError, null)
logout() // Redirect to login
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
},
)
export default api
4. Token Management in React
// useAuth.js - Custom hook for authentication
import { createContext, useContext, useState, useEffect } from 'react'
import api from './api'
const AuthContext = createContext()
export const useAuth = () => {
return useContext(AuthContext)
}
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null)
const [accessToken, setAccessToken] = useState(null)
const [loading, setLoading] = useState(true)
// Try to refresh token on app start
useEffect(() => {
const initAuth = async () => {
try {
const response = await api.post('/auth/refresh')
const { accessToken, user } = response.data
setAccessToken(accessToken)
setUser(user)
} catch (error) {
// No valid refresh token, user needs to login
console.log('No valid refresh token')
} finally {
setLoading(false)
}
}
initAuth()
}, [])
// Set Authorization header when token changes
useEffect(() => {
if (accessToken) {
api.defaults.headers.Authorization = `Bearer ${accessToken}`
} else {
delete api.defaults.headers.Authorization
}
}, [accessToken])
const login = async (email, password) => {
try {
const response = await api.post('/auth/login', { email, password })
const { accessToken, user } = response.data
setAccessToken(accessToken)
setUser(user)
return { success: true }
} catch (error) {
return {
success: false,
error: error.response?.data?.error || 'Login failed',
}
}
}
const logout = async () => {
try {
await api.post('/auth/logout')
} catch (error) {
console.log('Logout error:', error)
} finally {
setAccessToken(null)
setUser(null)
// Cookie will be cleared by server
}
}
const value = {
user,
accessToken,
setAccessToken, // Used by interceptor
login,
logout,
loading,
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
Security Best Practices
1. Cookie Security Settings
Always use these cookie options for production:
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // Prevents XSS
secure: true, // HTTPS only
sameSite: 'strict', // Prevents CSRF
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
domain: '.yourdomain.com', // Specify domain if needed
})
2. Token Rotation
Token rotation
means creating a new refresh token each time you refresh. This limits damage if a token is stolen:
// Create new refresh token on each refresh
const newRefreshToken = jwt.sign(
{ userId: user.id, tokenVersion: user.tokenVersion + 1 },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' },
)
// Update user's token version in database
await User.findByIdAndUpdate(user.id, {
tokenVersion: user.tokenVersion + 1,
})
3. Logout Endpoint
Clear cookies properly on logout:
app.post('/auth/logout', (req, res) => {
res.clearCookie('refreshToken', {
httpOnly: true,
secure: true,
sameSite: 'strict',
})
res.json({ message: 'Logged out successfully' })
})
Common Issues and Solutions
Issue 1: CORS Problems
When using cookies across domains, configure CORS properly:
app.use(
cors({
origin: 'http://localhost:3000', // Your frontend URL
credentials: true, // Allow cookies
}),
)
Issue 2: Development vs Production
Use different settings for development:
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTP in dev, HTTPS in prod
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
}
Issue 3: Multiple Refresh Requests
The request queue
in our interceptor prevents multiple refresh calls:
// This prevents sending multiple refresh requests
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
})
}
Testing Silent Refresh
1. Test Token Expiration
Create short-lived tokens for testing:
// Use 30 seconds for testing
const accessToken = jwt.sign(payload, secret, { expiresIn: '30s' })
2. Monitor Network Tab
Watch browser's Network tab to see:
- Failed request with 401 status
- Automatic refresh request
- Retry of original request with new token
3. Test Edge Cases
- What happens when refresh token expires?
- Multiple tabs making requests simultaneously
- User closes browser and reopens
Advanced Features
1. Token Validation Middleware
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization']
const token = authHeader && authHeader.split(' ')[1]
if (!token) {
return res.status(401).json({ error: 'Access token required' })
}
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid or expired token' })
}
req.user = user
next()
})
}
2. Rate Limiting for Refresh
Prevent abuse of refresh endpoint:
const rateLimit = require('express-rate-limit')
const refreshLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Limit each IP to 10 refresh requests per windowMs
message: 'Too many refresh attempts',
})
app.post('/auth/refresh', refreshLimiter, async (req, res) => {
// Refresh logic here
})
Conclusion
Silent refresh with HTTP-only cookies provides:
- Security: Tokens protected from XSS attacks
- User Experience: No login interruptions
- Automatic: Works without user interaction
- Reliable: Browser handles cookie management
Key points to remember:
- Use short-lived access tokens (15-30 minutes)
- Store refresh tokens in HTTP-only cookies
- Implement proper error handling and retry logic
- Test thoroughly in different scenarios
- Use token rotation for extra security
This approach is much safer than storing tokens in localStorage
and provides smooth user experience without compromising security.