DEV Community

Cover image for How to Test JWT Authentication and Silent Token Refresh in React Without a Backend
Nilesh Kumar
Nilesh Kumar

Posted on Originally published at playground.nileslabs.com

How to Test JWT Authentication and Silent Token Refresh in React Without a Backend

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);
  }
);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

3 Best Practices for Frontend JWT Security

  1. Keep Access Tokens in Memory: Never store short-lived access tokens in localStorage where they are vulnerable to Cross-Site Scripting (XSS). Store access tokens in a JavaScript variable or React Context.
  2. Prevent Refresh Token Storms (Queue Retries): If multiple simultaneous requests fail with 401, ensure only one /auth/refresh request is triggered while other pending requests wait in a queue.
  3. 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)