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
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)./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 |