All Posts
Error HandlingAuthenticationTesting

Testing API Authentication Error Handling: 401s, Expired Tokens, and Rate Limits

Kiran MayeeApril 25, 20259 min read

Your login flow works. The access token arrives, the API call succeeds, the user sees their dashboard. Ship it.

Two weeks later, a user reports a blank screen. Their access token expired. Your frontend tried to refresh it, but the refresh token was revoked. The refresh attempt returned a 401, but your error handler expected a JSON body — instead it got an HTML error page from the load balancer. TypeError. White screen of death.

Authentication error handling is the most under-tested area of frontend development. Here's how to fix that.

The Five Auth Failure Modes

Every OAuth-based application needs to handle these five scenarios:

1. Expired Access Token (401 Unauthorized)

Access tokens have short lifetimes (typically 15-60 minutes). When they expire, every API call returns 401. Your frontend needs to:

  • Detect the 401 response.
  • Silently exchange the refresh token for a new access token.
  • Retry the failed request with the new token.
  • Queue any concurrent requests while the refresh is in progress.

2. Revoked Refresh Token

Refresh tokens can be revoked (user logged out on another device, admin action, token rotation). When the refresh fails:

  • Clear all stored tokens.
  • Redirect to the login page.
  • Optionally show a session expired message.

3. Invalid or Malformed Token

A corrupted token in localStorage, a truncated token from a buggy interceptor, or a token signed with the wrong key. The API returns 401 immediately — no refresh attempt should be made.

4. Rate-Limited Auth Server

The auth server returns 429 Too Many Requests. Your frontend needs to:

  • Read the Retry-After header.
  • Wait before retrying.
  • Show a user-friendly message ("Please wait a moment").

5. Auth Server Down (5xx)

The auth server returns 500 or 503. Your frontend needs to:

  • Show a generic error message ("Authentication service unavailable").
  • Retry with exponential backoff.
  • Not redirect to login (the problem isn't the user's credentials).

Why These Are Hard to Test

With a real identity provider, you can't easily:

  • Expire a token on demand — you have to wait for the TTL or manually revoke it in the IdP dashboard.
  • Trigger rate limiting — you'd need to send hundreds of requests, which might get your test IP blocked.
  • Simulate server errors — Auth0 and Okta don't have a "return 500" button.
  • Control timing — testing "token expires during a multi-request operation" requires precise timing.

Error Simulation with Auth Sandbox

moqapi.dev's Auth Sandbox has a built-in error simulation engine. Configure it to inject specific errors at a controlled rate:

// Enable error simulation
POST /api/auth-sandbox/config
{
  "projectId": "your-project",
  "errorSimulation": {
    "enabled": true,
    "errorRate": 100,
    "errorTypes": ["expired_token"],
    "delayMs": 0
  }
}

With errorRate: 100 and errorTypes: ["expired_token"], every token request will return an expired token error. Your frontend's token refresh logic is tested directly.

Available Error Types

  • invalid_client — the client_id or client_secret is wrong (401).
  • expired_token — the token has expired (401).
  • rate_limited — too many requests (429 with Retry-After header).
  • server_error — internal server error (500).

Testing Strategy: Systematic Error Coverage

Here's a testing matrix for authentication error handling:

Integration Tests (Automated)

// Test 1: Expired access token triggers silent refresh
test('refreshes token on 401', async () => {
  // Get initial tokens
  const tokens = await getTokens();
  
  // Configure sandbox to return expired_token
  await configureErrorSimulation({ errorTypes: ['expired_token'], errorRate: 100 });
  
  // Make an API call — should get 401, trigger refresh, retry
  const response = await apiClient.get('/users/me');
  
  // Verify the refresh happened
  expect(response.status).toBe(200);
  expect(apiClient.accessToken).not.toBe(tokens.access_token);
});

// Test 2: Revoked refresh token redirects to login
test('redirects to login when refresh fails', async () => {
  // Revoke the refresh token
  await revokeToken(tokens.refresh_token);
  
  // Make an API call — 401, refresh fails, should redirect
  await apiClient.get('/users/me');
  
  expect(window.location.href).toContain('/login');
});

// Test 3: Rate limiting waits and retries
test('handles rate limiting with retry', async () => {
  await configureErrorSimulation({ errorTypes: ['rate_limited'], errorRate: 100 });
  
  const start = Date.now();
  const response = await apiClient.getTokens();
  const elapsed = Date.now() - start;
  
  // Should have waited for the Retry-After period
  expect(elapsed).toBeGreaterThan(1000);
});

Manual Testing (QA Checklist)

  • Enable expired_token errors → verify the loading spinner doesn't flash.
  • Enable server_error → verify the error banner appears with a retry button.
  • Enable rate_limited → verify the "please wait" message appears.
  • Set delayMs to 5000 → verify timeouts are handled gracefully.
  • Toggle errors off → verify normal operation resumes without a page reload.

Building a Resilient Auth Interceptor

Here's a battle-tested Axios interceptor that handles all five failure modes:

// axios-auth-interceptor.js
let isRefreshing = false;
let failedQueue = [];

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

api.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 api(originalRequest);
        });
      }
      
      originalRequest._retry = true;
      isRefreshing = true;
      
      try {
        const newTokens = await refreshAccessToken();
        processQueue(null, newTokens.access_token);
        originalRequest.headers.Authorization = 'Bearer ' + newTokens.access_token;
        return api(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError);
        redirectToLogin();
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }
    
    if (error.response?.status === 429) {
      const retryAfter = error.response.headers['retry-after'] || 5;
      await new Promise(r => setTimeout(r, retryAfter * 1000));
      return api(originalRequest);
    }
    
    return Promise.reject(error);
  }
);

Test this interceptor against the Auth Sandbox with each error type enabled. Every branch gets exercised.

Key Takeaways

  • Authentication has five failure modes: expired tokens, revoked refresh tokens, malformed tokens, rate limiting, and server errors.
  • Real identity providers make it nearly impossible to test these scenarios in isolation.
  • The Auth Sandbox's error simulation lets you inject specific auth errors at a controlled rate.
  • Build a resilient auth interceptor that handles all five modes, then test each one individually.
  • Systematic error testing prevents the blank screens and silent failures that ship to production.

Test every auth failure mode at moqapi.dev/signup.

Share this article:

About the Author

Kiran Mayee

Founder and sole developer of moqapi.dev. Full-stack engineer with deep experience in API platforms, serverless runtimes, and developer tooling. Built moqapi to solve the mock data and deployment friction she experienced firsthand building production APIs.

Ready to build?

Start deploying serverless functions in under a minute.

Get Started Free