Handling Session Expiry and Token Refresh

Loading

Proper session management is crucial for both security and user experience. Here’s a comprehensive approach to handling token expiration and refresh in React applications:

Core Implementation Patterns

1. Token Refresh Flow

// Auth service module
const refreshToken = async (refreshToken) => {
  try {
    const response = await axios.post('/auth/refresh', { refreshToken });
    return {
      accessToken: response.data.accessToken,
      refreshToken: response.data.refreshToken,
      expiresIn: response.data.expiresIn
    };
  } catch (error) {
    throw new Error('Refresh token failed');
  }
};

2. Axios Interceptor Setup

// apiClient.js
const apiClient = axios.create({ baseURL: API_BASE_URL });

apiClient.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const newTokens = await authService.refreshToken(
          authStore.getRefreshToken()
        );
        authStore.setTokens(newTokens);

        // Retry original request with new token
        originalRequest.headers.Authorization = `Bearer ${newTokens.accessToken}`;
        return apiClient(originalRequest);
      } catch (refreshError) {
        authStore.logout();
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);

React-Specific Implementation

1. Auth Context Provider

const AuthProvider = ({ children }) => {
  const [authState, setAuthState] = useState({
    accessToken: null,
    refreshToken: null,
    expiresAt: null
  });

  const refreshAuth = async () => {
    try {
      const newTokens = await authService.refreshToken(authState.refreshToken);
      setAuthState({
        accessToken: newTokens.accessToken,
        refreshToken: newTokens.refreshToken,
        expiresAt: Date.now() + newTokens.expiresIn * 1000
      });
      return true;
    } catch (error) {
      logout();
      return false;
    }
  };

  // Check token expiration before rendering protected routes
  const isAuthenticated = () => {
    if (!authState.accessToken || !authState.expiresAt) return false;

    // Add 5 minute buffer to prevent edge cases
    return authState.expiresAt > Date.now() + 300000;
  };

  return (
    <AuthContext.Provider value={{ ...authState, refreshAuth, isAuthenticated }}>
      {children}
    </AuthContext.Provider>
  );
};

2. Protected Route Component

const ProtectedRoute = ({ children }) => {
  const { isAuthenticated, refreshAuth } = useAuth();
  const [isRefreshing, setIsRefreshing] = useState(false);

  useEffect(() => {
    const checkAuth = async () => {
      if (!isAuthenticated()) {
        setIsRefreshing(true);
        const refreshed = await refreshAuth();
        if (!refreshed) {
          // Redirect to login if refresh fails
          navigate('/login');
        }
        setIsRefreshing(false);
      }
    };

    checkAuth();
  }, [isAuthenticated, refreshAuth]);

  if (isRefreshing) return <LoadingScreen />;

  return isAuthenticated() ? children : null;
};

Advanced Techniques

1. Silent Refresh (Background Token Renewal)

// In your AuthProvider component
useEffect(() => {
  if (!authState.accessToken) return;

  const expiresIn = authState.expiresAt - Date.now();
  const refreshBuffer = 5 * 60 * 1000; // 5 minutes before expiry

  // Set timeout to refresh token before it expires
  const refreshTimer = setTimeout(async () => {
    try {
      await refreshAuth();
    } catch (error) {
      console.error('Background refresh failed:', error);
    }
  }, expiresIn - refreshBuffer);

  return () => clearTimeout(refreshTimer);
}, [authState.accessToken, authState.expiresAt]);

2. Concurrent Request Handling

let isRefreshing = false;
let failedQueue = [];

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

  failedQueue = [];
};

apiClient.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 apiClient(originalRequest);
        }).catch(err => {
          return Promise.reject(err);
        });
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        const newTokens = await authService.refreshToken(
          authStore.getRefreshToken()
        );
        authStore.setTokens(newTokens);
        processQueue(null, newTokens.accessToken);
        originalRequest.headers.Authorization = `Bearer ${newTokens.accessToken}`;
        return apiClient(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError, null);
        authStore.logout();
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

Error Handling and User Experience

1. Session Expiry Notification

// Custom hook for session monitoring
const useSessionTimeout = (logoutCallback) => {
  useEffect(() => {
    const checkSession = async () => {
      try {
        await authService.checkSession(); // Simple endpoint that validates token
      } catch (error) {
        if (error.response?.status === 401) {
          showTimeoutNotification(() => {
            logoutCallback();
          });
        }
      }
    };

    const interval = setInterval(checkSession, 300000); // Check every 5 minutes
    return () => clearInterval(interval);
  }, [logoutCallback]);
};

2. Graceful Logout Handling

const logout = async (isSessionExpired = false) => {
  try {
    await authService.revokeToken(authState.refreshToken);
  } catch (error) {
    console.error('Token revocation failed:', error);
  } finally {
    setAuthState({
      accessToken: null,
      refreshToken: null,
      expiresAt: null
    });

    if (isSessionExpired) {
      navigate('/login?session_expired=true');
    } else {
      navigate('/login');
    }
  }
};

Security Considerations

1. Token Storage

// Secure token storage with httpOnly cookies is preferred
// If using localStorage:
const secureStorage = {
  setItem: (key, value) => {
    try {
      const encrypted = encrypt(value);
      localStorage.setItem(key, encrypted);
    } catch (error) {
      console.error('Secure storage failed:', error);
    }
  },
  getItem: (key) => {
    try {
      const value = localStorage.getItem(key);
      return value ? decrypt(value) : null;
    } catch (error) {
      console.error('Secure retrieval failed:', error);
      return null;
    }
  }
};

2. Short-Lived Tokens

// Recommended token lifetimes:
// - Access token: 15-30 minutes
// - Refresh token: 7-30 days (with rotation)

Testing Strategies

1. Mock API Responses

// Jest test example
describe('Token refresh flow', () => {
  beforeEach(() => {
    axiosMock.onPost('/auth/refresh').replyOnce(200, {
      accessToken: 'new-access-token',
      refreshToken: 'new-refresh-token',
      expiresIn: 1800
    });
  });

  it('should refresh token when 401 received', async () => {
    axiosMock.onGet('/protected').replyOnce(401);
    axiosMock.onGet('/protected').replyOnce(200, { data: 'success' });

    const response = await apiClient.get('/protected');
    expect(response.data).toBe('success');
  });
});

2. Integration Tests

describe('ProtectedRoute', () => {
  it('should redirect to login when session expires', async () => {
    render(
      <AuthProvider initialTokens={{ expiresAt: Date.now() - 1000 }}>
        <ProtectedRoute>
          <div>Protected Content</div>
        </ProtectedRoute>
      </AuthProvider>
    );

    await waitFor(() => {
      expect(mockNavigate).toHaveBeenCalledWith('/login');
    });
  });
});

Best Practices

  1. Implement proper token rotation – Issue new refresh token on each refresh
  2. Use secure cookie attributesSameSite, Secure, HttpOnly
  3. Monitor refresh token usage – Detect suspicious activity
  4. Provide clear user feedback – When session is about to expire
  5. Log token refresh events – For security auditing
  6. Limit refresh token reuse – Implement one-time-use policy
  7. Consider device fingerprinting – For additional security

Leave a Reply

Your email address will not be published. Required fields are marked *