Code To Learn logo

Code To Learn

M7: Forms & Authentication

L12: Refresh Token When Expired

Automatically refresh expired access tokens

Let's implement automatic token refresh to keep users signed in seamlessly! 🔄

The Token Expiration Problem

What happens when access token expires:

User signs in

Access token: Valid for 15 minutes

User browses site (10 minutes)

Access token still valid → Requests succeed ✅

User continues browsing (20 minutes total)

Access token expired → Requests fail with 401 ❌

Without auto-refresh: User must sign in again 😞
With auto-refresh: Token refreshes automatically 😊

Understanding Token Refresh

Two-token system:

// Access Token (short-lived)
{
  token: "eyJhbGc...",
  expiresIn: 900,     // 15 minutes
  storage: "memory",  // JavaScript variable
  purpose: "API requests"
}

// Refresh Token (long-lived)
{
  token: "eyJhbGc...",
  expiresIn: 2592000,        // 30 days
  storage: "HTTP-only cookie", // Secure cookie
  purpose: "Get new access token"
}

Why two tokens?

Implement Token Refresh

Update the response interceptor to handle 401 errors:

src/lib/api.js
import axios from 'axios';
import { tokenStore } from './tokenStore';

const api = axios.create({
  baseURL: 'https://v2.api.noroff.dev',
  headers: {
    'Content-Type': 'application/json',
  },
  withCredentials: true,
});

// Track if we're currently refreshing
let isRefreshing = false;
let failedQueue = [];

// Process failed requests after refresh
const processQueue = (error, token = null) => {
  failedQueue.forEach(prom => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token);
    }
  });
  
  failedQueue = [];
};

// REQUEST INTERCEPTOR
api.interceptors.request.use(
  (config) => {
    const token = tokenStore.getToken();
    
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// RESPONSE INTERCEPTOR with auto-refresh
api.interceptors.response.use(
  (response) => {
    return response;
  },
  async (error) => {
    const originalRequest = error.config;

    // Check if error is 401 and we haven't retried yet
    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // 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 {
        // Request new access token
        const response = await api.get('/auth/refresh');
        const { accessToken } = response.data;

        // Update token store
        tokenStore.setToken(accessToken);

        // Update original request with new token
        originalRequest.headers.Authorization = `Bearer ${accessToken}`;

        // Process queued requests
        processQueue(null, accessToken);

        // Retry original request
        return api(originalRequest);
      } catch (refreshError) {
        // Refresh failed, sign out user
        processQueue(refreshError, null);
        tokenStore.clearToken();
        
        // Redirect to sign-in (if in browser context)
        if (typeof window !== 'undefined') {
          window.location.href = '/sign-in';
        }
        
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

export default api;

Update AuthContext

Add a method to update token from outside:

src/contexts/AuthContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '@/lib/api';
import { tokenStore } from '@/lib/tokenStore';

const AuthContext = createContext(undefined);

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
};

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  const [token, setToken] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const navigate = useNavigate();

  // Sync token with tokenStore
  useEffect(() => {
    if (token) {
      tokenStore.setToken(token);
    } else {
      tokenStore.clearToken();
    }
  }, [token]);

  // Fetch token on mount
  useEffect(() => {
    const fetchToken = async () => {
      try {
        const response = await api.get('/auth/refresh');
        const { accessToken, data } = response.data;
        setToken(accessToken);
        setUser(data);
      } catch (error) {
        console.log('No valid session');
      } finally {
        setIsLoading(false);
      }
    };
    fetchToken();
  }, []);

  // Update token (can be called from outside)
  const updateToken = (newToken) => {
    setToken(newToken);
  };

  // Sign in
  const signIn = async (email, password) => {
    try {
      const response = await api.post('/auth/login', {
        email,
        password,
      });

      const { accessToken, data } = response.data;
      setToken(accessToken);
      setUser(data);
      navigate('/');

      return { success: true };
    } catch (error) {
      const message = error.response?.data?.errors?.[0]?.message 
        || 'Invalid email or password';
      return { success: false, error: message };
    }
  };

  // Sign out
  const signOut = async () => {
    try {
      await api.post('/auth/logout');
    } catch (error) {
      console.error('Logout error:', error);
    } finally {
      setUser(null);
      setToken(null);
      navigate('/sign-in');
    }
  };

  const value = { user, token, isLoading, signIn, signOut, updateToken };
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

Understanding the Code

Step-by-step refresh flow:

1. User makes API request
   api.get('/venues')

2. Request has expired token
   Authorization: Bearer (expired)

3. Server responds with 401
   { errors: [{ message: "Token expired" }] }

4. Response interceptor catches 401
   if (status === 401 && !originalRequest._retry)

5. Check if already refreshing
   if (isRefreshing) → Queue request
   else → Start refresh

6. Request new access token
   api.get('/auth/refresh')

7. Server validates refresh token (cookie)
   HttpOnly cookie sent automatically

8. New access token returned
   { accessToken: "eyJhbGc..." }

9. Update token store
   tokenStore.setToken(newAccessToken)

10. Retry original request with new token
    api(originalRequest)

11. Success!
    User never noticed anything

Preventing infinite loops:

originalRequest._retry = true;

// First attempt:
if (!originalRequest._retry) {
  // Try to refresh
}

// After refresh, retry request:
api(originalRequest)  // Now has _retry = true

// If this fails with 401 again:
if (originalRequest._retry) {
  // Don't refresh again, just fail
  return Promise.reject(error);
}

Why _retry flag?

Without _retry:
  Request → 401 → Refresh → Retry
  → 401 → Refresh → Retry
  → 401 → Refresh → Retry
  → Infinite loop! ❌

With _retry:
  Request → 401 → Refresh → Retry (_retry=true)
  → 401 → Don't refresh, fail ✅

Why queue requests?

Scenario: Multiple requests fail at same time

Request 1: GET /venues     → 401
Request 2: GET /bookings   → 401
Request 3: POST /favorites → 401

Without queuing:
  Request 1 → Refresh → Retry ✅
  Request 2 → Refresh → Retry ✅ (unnecessary refresh!)
  Request 3 → Refresh → Retry ✅ (unnecessary refresh!)
  Total: 3 refresh calls (2 wasted)

With queuing:
  Request 1 → Start refresh
  Request 2 → Wait in queue
  Request 3 → Wait in queue
  Refresh completes
  → Process queue with new token
  Request 1 → Retry ✅
  Request 2 → Retry ✅
  Request 3 → Retry ✅
  Total: 1 refresh call (efficient!)

Queue implementation:

let failedQueue = [];

// Request fails, refresh in progress
if (isRefreshing) {
  return new Promise((resolve, reject) => {
    // Add to queue
    failedQueue.push({ resolve, reject });
  });
  // Promise waits until processQueue is called
}

// After refresh succeeds
processQueue(null, newToken);

// Process queue:
failedQueue.forEach(prom => {
  prom.resolve(newToken);  // Resolves waiting promises
});

// Waiting promises continue:
.then(token => {
  originalRequest.headers.Authorization = `Bearer ${token}`;
  return api(originalRequest);  // Retry with new token
})

Race condition scenarios:

Scenario 1: Simultaneous requests

Time 0ms:  Request A starts
Time 10ms: Request B starts
Time 50ms: Request A fails (401)
Time 55ms: Request B fails (401)

Without protection:
  50ms: A starts refresh
  55ms: B starts refresh (race condition!)
  → Two refresh calls

With isRefreshing flag:
  50ms: A starts refresh (isRefreshing = true)
  55ms: B checks isRefreshing → Queue B
  70ms: Refresh completes
  → Process A and B with same token ✅

Scenario 2: Refresh during refresh

let isRefreshing = false;

// Request 1
if (isRefreshing) {
  // Queue
} else {
  isRefreshing = true;  // Lock
  await refresh();
  isRefreshing = false; // Unlock
}

// Request 2 (happens during Request 1 refresh)
if (isRefreshing) {
  // Queued, waits for unlock
}

Scenario 3: Failed refresh

Request A → 401 → Start refresh
Request B → 401 → Queue
Refresh fails (refresh token expired)
→ Must fail both A and B

processQueue(error, null);
// Rejects all queued promises
// Redirects to sign-in

Different error scenarios:

// 1. Token expired (refresh succeeds)
try {
  const response = await api.get('/venues');
} catch (error) {
  // Interceptor handles it automatically
  // User never sees error
}

// 2. Refresh token expired (refresh fails)
try {
  const response = await api.get('/venues');
} catch (error) {
  // Interceptor redirects to /sign-in
  // User must sign in again
}

// 3. Network error (no retry)
try {
  const response = await api.get('/venues');
} catch (error) {
  // Not a 401, doesn't trigger refresh
  // Component handles error
  setError('Network error. Please try again.');
}

// 4. Server error (500, etc.)
try {
  const response = await api.get('/venues');
} catch (error) {
  // Not a 401, doesn't trigger refresh
  // Component handles error
  setError('Server error. Please try later.');
}

Refresh failure handling:

catch (refreshError) {
  // Clear token
  tokenStore.clearToken();
  
  // Fail all queued requests
  processQueue(refreshError, null);
  
  // Redirect to sign-in
  if (typeof window !== 'undefined') {
    window.location.href = '/sign-in';
  }
  
  return Promise.reject(refreshError);
}

Why window.location.href?

// Option 1: navigate (React Router)
navigate('/sign-in');
// ❌ Doesn't work in interceptor (no hook context)

// Option 2: window.location.href
window.location.href = '/sign-in';
// ✅ Works anywhere, forces full page reload

// Option 3: Emit event
window.dispatchEvent(new CustomEvent('auth:expired'));
// AuthContext listens and calls navigate()
// ✅ Works, but more complex

Testing Token Refresh

Sign in and get token:

  1. Sign in to your account
  2. Open DevTools → Application → Cookies
  3. Verify refreshToken cookie exists

Simulate expired token:

// In browser console
import { tokenStore } from './lib/tokenStore';

// Set invalid token to simulate expiration
tokenStore.setToken('invalid-token');

Or manually edit token in React DevTools.

Make API request:

// Click button or trigger API call
const response = await api.get('/holidaze/venues');

Observe automatic refresh:

Network tab should show:

1. GET /holidaze/venues  → 401 (Unauthorized)
2. GET /auth/refresh     → 200 (Success)
3. GET /holidaze/venues  → 200 (Retried with new token)

Automatic refresh working!

Test multiple simultaneous requests:

// Trigger multiple requests at once
Promise.all([
  api.get('/holidaze/venues'),
  api.get('/holidaze/bookings/mine'),
  api.get('/holidaze/profiles/me'),
]);

Network tab should show:

1. GET /venues   → 401
2. GET /bookings → 401
3. GET /profiles → 401
4. GET /auth/refresh → 200 (Single refresh!)
5. GET /venues   → 200 (All retry)
6. GET /bookings → 200
7. GET /profiles → 200

Request queuing working!

Test refresh token expiration:

// Delete refresh token cookie
document.cookie = 'refreshToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC;';

// Make API request
const response = await api.get('/holidaze/venues');
// Should redirect to /sign-in

Expired refresh token handled!

Handling Edge Cases

What's Next?

In Lesson 13, we'll:

  1. Create a Route guard component
  2. Protect specific routes (require sign-in)
  3. Redirect unauthenticated users
  4. Handle loading states during auth check

✅ Lesson Complete! Automatic token refresh implemented!

Key Takeaways

  • Access tokens expire quickly (15 min), refresh tokens last longer (30 days)
  • 401 errors trigger automatic token refresh
  • Request queuing prevents multiple simultaneous refresh calls
  • _retry flag prevents infinite refresh loops
  • isRefreshing flag coordinates multiple requests
  • Failed refresh redirects to sign-in
  • Successful refresh retries original request transparently
  • User experience is seamless (no re-authentication needed)
  • HTTP-only cookies store refresh token securely
  • Race conditions handled with proper locking