Testing API Authentication Error Handling: 401s, Expired Tokens, and Rate Limits
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-Afterheader. - 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.
About the Author
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.
Related Articles
What Is Mock Data and Why It Matters for Modern Development
Understand mock data, its role in frontend and backend testing, and how moqapi.dev automates the creation of realistic test payloads for every API endpoint.
API Testing Strategies for Modern Engineering Teams
Contract tests, snapshot tests, fuzz testing — explore the testing matrix every team needs, with examples using Node.js, Python, and moqapi.dev.
API Mocking vs Stubbing vs Faking: The Developer's Definitive Guide
These three terms are used interchangeably but mean very different things. Understand when to use each technique and how they affect your test quality.
Ready to build?
Start deploying serverless functions in under a minute.