Wiring JWT Authentication End-to-End — React to Express

Every piece of the MERN Blog authentication system is now built independently — the AuthContext on the React side, the protect middleware and JWT signing on the Express side, and the Axios interceptor that attaches tokens automatically. In this final lesson you will verify that all these pieces work together correctly end-to-end: register a user, confirm the token is stored and attached to subsequent requests, navigate to a protected route, experience a token expiry handled gracefully, and log out cleanly. You will also address the one remaining edge case — what happens when the Axios interceptor’s 401 handler fires during the token refresh call itself.

The Complete End-to-End Flow

Step 1: App loads
  AuthContext.useEffect runs:
    1a. Read token from localStorage
    1b. If token exists → GET /api/auth/me (Axios adds Bearer header via interceptor)
    1c. Server verifies token → returns current user
    1d. AuthContext: setUser(user), setLoading(false)
    1e. If token missing or invalid → setUser(null), setLoading(false)

Step 2: User visits /dashboard
  ProtectedRoute checks: if (loading) → <Spinner />
  Once loading=false:
    if (!user) → <Navigate to="/login" />
    if (user)  → render <DashboardPage />

Step 3: DashboardPage fetches user's posts
  postService.getByUser(user._id) → Axios adds Bearer header automatically
  Express: protect middleware verifies token → controller runs
  Posts returned → displayed

Step 4: User creates a post
  postService.create(formData) → Axios adds Bearer header
  Express: protect validates → Post.create() → 201 response
  React navigates to new post

Step 5: Token expires (after JWT_EXPIRES_IN)
  Next API call returns 401
  Axios response interceptor catches 401
  Interceptor: localStorage.removeItem('token') + dispatch logout action
  AuthContext: setUser(null)
  ProtectedRoute: redirects to /login

Step 6: Logout
  Header calls logout() from useAuth()
  AuthContext: localStorage.removeItem('token'), setUser(null)
  React Router: user is no longer authenticated
  ProtectedRoute redirects away from protected pages
Note: The Axios response interceptor that handles 401 by clearing the token and redirecting to login will also fire if the GET /api/auth/me call in AuthContext’s startup useEffect returns a 401. This is actually the correct behaviour — an invalid or expired stored token should result in the user being logged out. Make sure the interceptor does not redirect if the user is already on the login page (check window.location.pathname !== '/login' before redirecting).
Tip: Test the complete auth flow end-to-end with both Postman (for the API) and the browser (for the React client) before considering the auth system complete. The most common integration bugs are: (1) CORS blocking the auth endpoints, (2) the Vite proxy not forwarding the Authorization header, (3) AuthContext not handling the loading state correctly, causing a flash redirect to login. Postman isolates the API bugs; the browser reveals the React integration bugs.
Warning: Beware of infinite redirect loops. If the Axios interceptor redirects to /login on every 401, and the login page itself makes an API call that returns 401 (e.g. checking if the user is already logged in), you get an infinite loop. Guard against this: only redirect on 401 if the failing request URL is not /api/auth/login or /api/auth/refresh.

The Final Axios Interceptor — With Loop Guard

// src/services/api.js — complete with loop guard
import axios from 'axios';

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || '/api',
  timeout: 15000,
});

api.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

api.interceptors.response.use(
  (res) => res,
  (err) => {
    const status = err.response?.status;
    const url    = err.config?.url || '';

    // Only handle 401 on non-auth endpoints (avoid redirect loops)
    if (status === 401 &&
        !url.includes('/auth/login') &&
        !url.includes('/auth/register') &&
        !url.includes('/auth/refresh') &&
        !url.includes('/auth/me')) {

      localStorage.removeItem('token');

      // Only redirect if not already on the login page
      if (window.location.pathname !== '/login') {
        window.location.href = '/login';
      }
    }

    return Promise.reject(err);
  }
);

End-to-End Testing Checklist

── Postman: API layer ────────────────────────────────────────────────────────
✓ POST /api/auth/register → 201 + token
✓ POST /api/auth/login    → 200 + token
✓ GET  /api/auth/me       → 200 + user (with valid token)
✓ GET  /api/auth/me       → 401 (no token)
✓ GET  /api/auth/me       → 401 "session has expired" (expired token)
✓ POST /api/posts         → 201 (with token, valid data)
✓ POST /api/posts         → 401 (no token)
✓ POST /api/posts         → 403 (token for non-author role if restricted)
✓ PATCH /api/posts/:id    → 403 (different user's post)

── Browser: React client ────────────────────────────────────────────────────
✓ /register → fills form → submits → redirects to /dashboard
✓ /login → fills form → redirects to previously requested protected page
✓ /dashboard without login → redirects to /login
✓ Refresh page on /dashboard → shows spinner briefly → stays on dashboard
✓ Header shows user name when logged in
✓ Header shows Login/Register when not logged in
✓ Logout button → clears session → redirected from protected pages
✓ Creating a post → redirects to new post detail page

Wiring AuthContext.login() to the LoginPage Form

// src/pages/LoginPage.jsx — clean, no direct API calls
import { useState }                       from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth }                        from '@/context/AuthContext';
import FormField    from '@/components/ui/FormField';
import SubmitButton from '@/components/ui/SubmitButton';

function LoginPage() {
  const { login }  = useAuth();
  const navigate   = useNavigate();
  const location   = useLocation();
  const from       = location.state?.from || '/dashboard';

  const [formData, setFormData] = useState({ email: '', password: '' });
  const [errors,   setErrors]   = useState({});
  const [apiError, setApiError] = useState('');
  const [loading,  setLoading]  = useState(false);

  const handleChange = (e) => {
    setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }));
    if (errors[e.target.name]) setErrors(prev => ({ ...prev, [e.target.name]: '' }));
    setApiError('');
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    const errs = {};
    if (!formData.email)    errs.email    = 'Email is required';
    if (!formData.password) errs.password = 'Password is required';
    if (Object.keys(errs).length) { setErrors(errs); return; }

    setLoading(true);
    try {
      await login(formData.email, formData.password); // AuthContext handles token storage
      navigate(from, { replace: true });
    } catch (err) {
      setApiError(err.response?.data?.message || 'Login failed');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="auth-page">
      <h1>Log In</h1>
      {apiError && <div className="alert alert--error">{apiError}</div>}
      <form onSubmit={handleSubmit} noValidate>
        <FormField label="Email"    name="email"    type="email"    value={formData.email}
          onChange={handleChange} error={errors.email}    required disabled={loading} />
        <FormField label="Password" name="password" type="password" value={formData.password}
          onChange={handleChange} error={errors.password} required disabled={loading} />
        <SubmitButton label="Log In" loadingLabel="Logging in..." loading={loading} fullWidth />
      </form>
      <p>No account? <Link to="/register">Register</Link></p>
    </div>
  );
}
export default LoginPage;

Common Mistakes

Mistake 1 — Axios interceptor redirects on auth endpoint 401s

❌ Wrong — login endpoint returns 401 (wrong password) → interceptor redirects to /login again:

if (err.response?.status === 401) {
  window.location.href = '/login'; // fires on failed login attempt!
}

✅ Correct — guard with URL check:

if (status === 401 && !url.includes('/auth/login')) {
  window.location.href = '/login'; // only for non-auth routes ✓
}

Mistake 2 — AuthContext.login() re-fetches the user instead of using the response

❌ Wrong — extra unnecessary API call:

const login = async (email, password) => {
  await api.post('/auth/login', { email, password });
  const me = await api.get('/auth/me'); // unnecessary — login response has the user!
  setUser(me.data.data);
};

✅ Correct — use the data from the login response:

const login = async (email, password) => {
  const res = await api.post('/auth/login', { email, password });
  localStorage.setItem('token', res.data.token);
  setUser(res.data.data); // ✓ user data already in response
};

Mistake 3 — Not awaiting login() in the form submit handler

❌ Wrong — navigation happens before login completes:

login(email, password); // not awaited
navigate('/dashboard'); // fires immediately — user not yet authenticated!

✅ Correct — always await the login action:

await login(email, password); // ✓
navigate('/dashboard');       // fires only after login completes

Quick Reference — End-to-End Auth Checklist

Layer Verified When
Express: JWT signing POST /login returns a token
Express: protect middleware GET /auth/me with valid token returns user
Express: 401 on invalid token GET /auth/me with no token returns 401
React: token stored after login localStorage.getItem(‘token’) is set
React: Axios interceptor Protected API calls include Authorization header
React: AuthContext on refresh Dashboard survives page reload
React: ProtectedRoute loading No flash redirect on refresh
React: logout Token cleared, user null, redirect from protected pages

🧠 Test Yourself

A user is on the dashboard when their token expires. They click a button that calls postService.getAll(). What happens, step by step?