I once lost an entire Saturday afternoon trying to debug a silent JWT token refresh loop in a React app I was building.
The login modal and user dashboard were completely done. But testing the actual security mechanics was driving me up the wall:
- How was I supposed to test what happens when an access token expires after 15 minutes?
- How could I verify that my Axios interceptor cleanly caught the
401 Unauthorized, paused queued requests, called/auth/refresh, and retried the original request without logging the user out? - How could I test updating authenticated profile data via
PATCH /auth/me?
The backend team wasn't ready yet, and none of the public mock APIs in the ecosystem offered realistic JWT authentication. They either didn't support auth at all, or returned static dummy strings that couldn't be decoded or verified.
To test a simple React AuthContext and an Axios interceptor loop, I was suddenly forced to stop frontend work and spend hours building a custom Express auth microservice with bcrypt, jsonwebtoken, database tables, and refresh token rotation logic from scratch.
It was infuriating. Frontend engineers shouldn't have to build an entire custom authentication backend just to test whether their Bearer token interceptor and login redirects work.
I thought: Why can't a mock API offer real, verifiable JWT authentication with /auth/login, /auth/refresh, and /auth/me built directly into the sandbox?
That frustration was the exact reason I built a full JWT Auth Simulation engine into Playground API.
In this guide, I'll walk you through how to implement and test a complete React authentication lifecycle ā including login, token storage, silent refresh interceptors, and protected routes ā without writing backend code.
The Standard JWT Authentication Architecture
A production frontend authentication flow typically follows this sequence:
sequenceDiagram
autonumber
actor User as React Application
participant Auth as Auth API Sandbox
User->>Auth: 1. POST /auth/login { username, password }
Auth-->>User: 200 OK { access_token (15m), refresh_token (7d), user }
User->>Auth: 2. GET /auth/me (Authorization: Bearer <access_token>)
Auth-->>User: 200 OK { id: 1, name: "Leanne Graham", ... }
Note over User,Auth: Time passes... Access token expires
User->>Auth: 3. GET /auth/me (Expired Token)
Auth-->>User: 401 Unauthorized
User->>Auth: 4. POST /auth/refresh { refreshToken }
Auth-->>User: 200 OK { access_token (Fresh 15m) }
User->>Auth: 5. Retry original GET /auth/me with fresh token
Auth-->>User: 200 OK
The Sandbox Authentication Endpoints
Playground API by Niles Labs provides built-in JWT authentication simulation endpoints under /api/v1/auth:
| Endpoint | Method | Payload / Headers | Description |
|---|---|---|---|
/api/v1/auth/login |
POST |
{ username, email, password } |
Authenticates user; returns signed 15-minute access_token and 7-day refresh_token. |
/api/v1/auth/register |
POST |
{ name, username, email } |
Creates user in your session overlay and returns auth tokens. |
/api/v1/auth/refresh |
POST |
{ refreshToken } |
Validates refresh token and issues a fresh access_token. |
/api/v1/auth/me |
GET |
Authorization: Bearer <token> |
Verifies JWT signature and returns the authenticated user profile. |
/api/v1/auth/me |
PATCH |
Authorization: Bearer <token> |
Updates profile attributes within the caller's session overlay. |
Implementing the Silent Refresh Interceptor with Axios
Let's implement a production-grade Axios client that automatically catches 401 Unauthorized errors, performs a silent token refresh, and retries the failed request.
1. The Authenticated HTTP Client (httpClient.js)
// src/services/httpClient.js
import axios from 'axios';
const BASE_URL = 'https://playground.nileslabs.com/api/v1';
// In-memory token storage (Best practice for XSS mitigation)
let accessToken = null;
let refreshToken = localStorage.getItem('playground_refresh_token');
export const setTokens = (access, refresh) => {
accessToken = access;
refreshToken = refresh;
if (refresh) {
localStorage.setItem('playground_refresh_token', refresh);
} else {
localStorage.removeItem('playground_refresh_token');
}
};
export const getAccessToken = () => accessToken;
// Create Axios Instance
export const apiClient = axios.create({
baseURL: BASE_URL,
headers: { 'Content-Type': 'application/json' },
});
// Request Interceptor: Attach Bearer token
apiClient.interceptors.request.use((config) => {
if (accessToken && !config.headers.Authorization) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// Response Interceptor: Catch 401 and handle silent refresh
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Check if error is 401 and we haven't already retried
if (error.response?.status === 401 && !originalRequest._retry && refreshToken) {
originalRequest._retry = true;
try {
// Request fresh access token
const refreshRes = await axios.post(`${BASE_URL}/auth/refresh`, {
refreshToken: refreshToken,
});
const newAccessToken = refreshRes.data.access_token;
setTokens(newAccessToken, refreshToken);
// Update Authorization header and retry original request
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return apiClient(originalRequest);
} catch (refreshErr) {
// Refresh token failed or expired -> log out user
setTokens(null, null);
window.location.href = '/login';
return Promise.reject(refreshErr);
}
}
return Promise.reject(error);
}
);
2. The React AuthContext Provider (AuthContext.jsx)
// src/context/AuthContext.jsx
import React, { createContext, useContext, useState, useEffect } from 'react';
import { apiClient, setTokens, getAccessToken } from '../services/httpClient';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// Fetch current user on startup if token exists
useEffect(() => {
const initAuth = async () => {
const storedRefresh = localStorage.getItem('playground_refresh_token');
if (storedRefresh) {
try {
// Attempt token refresh on boot
const refreshRes = await apiClient.post('/auth/refresh', {
refreshToken: storedRefresh,
});
setTokens(refreshRes.data.access_token, storedRefresh);
// Fetch authenticated profile
const meRes = await apiClient.get('/auth/me');
setUser(meRes.data);
} catch {
setTokens(null, null);
}
}
setLoading(false);
};
initAuth();
}, []);
// Login action
const login = async (username, password) => {
const res = await apiClient.post('/auth/login', { username, password });
setTokens(res.data.access_token, res.data.refresh_token);
setUser(res.data.user);
return res.data.user;
};
// Logout action
const logout = () => {
setTokens(null, null);
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
3. Protected Route Wrapper (ProtectedRoute.jsx)
// src/components/ProtectedRoute.jsx
import React from 'react';
import { useAuth } from '../context/AuthContext';
export default function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
if (loading) {
return <div style={{ padding: '2rem' }}>š Verifying session...</div>;
}
if (!user) {
return (
<div style={{ padding: '2rem', color: '#dc2626' }}>
<h3>š« Access Denied</h3>
<p>Please log in to view this protected dashboard.</p>
</div>
);
}
return children;
}
3 Best Practices for Frontend JWT Security
-
Keep Access Tokens in Memory:
Never store short-lived access tokens in
localStoragewhere they are vulnerable to Cross-Site Scripting (XSS). Store access tokens in a JavaScript variable or React Context. -
Prevent Refresh Token Storms (Queue Retries):
If multiple simultaneous requests fail with 401, ensure only one
/auth/refreshrequest is triggered while other pending requests wait in a queue. - Handle Expired Refresh Tokens Gracefully: If the refresh token itself is invalid or expired, immediately clear client state and redirect the user to the login screen with a friendly message.
Conclusion
Authentication doesn't have to be a blind spot in your frontend development workflow. By pairing a robust Axios interceptor with a real JWT sandbox that verifies tokens and simulates refresh lifecycles, you can test and bulletproof your authentication flows without writing a line of backend auth code.
Test JWT login, refresh tokens, and protected routes with Playground API by Niles Labs.
Top comments (0)