Silent Refresh Using HTTP-Only Cookies: Secure Token Management

Cover

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.

References