401 vs. 403: what the frontend should do with each (and why mixing them up locks users in a loop)
A 401 and a 403 both mean "no", but they ask the frontend for opposite reactions. Treating them the same sends users to a login screen that can't help them. Here is how the auth admin panel handles each one, why route protection is client-side when cookies live on another origin, and the four states every protected app should render.
The problem
Treating every auth error as "logged out" would send users with a valid session but no access to a login screen that could never let them in.
The result
401 clears the session and shows login; 403 shows a dead-end screen with the backend's real reason; the app always renders one of four clear states.
Stack
The problem
Every frontend that talks to a protected API eventually writes an interceptor like this:
api.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401 || error.response?.status === 403) {
logout();
redirect('/login');
}
return Promise.reject(error);
});
It looks reasonable: "the server said no, so log the user out". But 401 and 403 mean very different things, and lumping them together creates a frustrating loop:
- a user with a perfectly valid session opens something they're not allowed to see,
- the server answers 403,
- the app logs them out and shows the login screen,
- they log in again, successfully, because their identity is fine,
- they open the same page, get 403 again, and are logged out again.
Logging in again can't change an authorization decision. The login screen is simply the wrong answer.
In our auth system this isn't hypothetical. When an admin disables an application, or revokes someone's access, live sessions start receiving 403s, and the frontend must show why, not a login form.
The details
Two different questions
| Status | Meaning | Is the session OK? | Will logging in again help? |
|---|---|---|---|
| 401 Unauthorized | "I don't know who you are" (no session, expired, revoked) | no | yes |
| 403 Forbidden | "I know who you are, and the answer is no" | yes | no |
The names are historically confusing (401 is really about authentication), but the behavior is clear: 401 is fixable by logging in; 403 is not.
Where the session lives
The auth admin panel is a Next.js app. Its session is three HttpOnly cookies set by the auth backend, on the backend's origin, not the frontend's. That has two consequences:
- JavaScript can't read the cookies (that's the point of HttpOnly),
- Next.js middleware can't see them either, because they don't belong to the frontend's origin.
So "is this user logged in?" can only be answered by asking the backend.
The challenges
- The interceptor must not be clever with 403s. But individual screens still need to show 403 reasons in context.
- No retries on 401. The backend already tries to refresh the access token on its own before answering. A 401 that reaches the browser means the session is really dead, and retrying would just loop.
- Route protection without middleware. The usual Next.js approach doesn't work when cookies live on another origin.
- Not every logged-in user is an admin. A valid user without the Root Admin role must see a polite refusal, not a broken app.
- Loading must not flash the wrong screen. While "who am I?" is in flight, the app must not briefly show the login page.
The solution
1. One axios instance, credentials always included
export const api = axios.create({
baseURL: config.apiUrl,
withCredentials: true, // this is what makes the HttpOnly cookies travel
headers: { 'Content-Type': 'application/json' },
});
Without withCredentials: true, the browser doesn't send cookies on cross-origin requests, and nothing works. On the backend side, CORS must allow credentials for the panel's origin too. Both halves are required.
2. The interceptor handles 401, and only 401
let unauthorizedHandler: (() => void) | null = null;
/** Registered once by the query provider, so this file never imports React. */
export function setUnauthorizedHandler(handler: () => void) {
unauthorizedHandler = handler;
}
api.interceptors.response.use(
(response) => response,
(error: unknown) => {
// The backend already tried to refresh the access token on its side.
// A 401 here means the session is really dead: never retry.
//
// A 403 is deliberately NOT handled here. The session is alive and the
// answer is still no (app disabled or deleted, access revoked). Clearing
// the session would turn that into a login screen, and logging in again
// would change nothing. The caller shows the backend's reason instead.
if (axios.isAxiosError(error) && error.response?.status === 401) {
unauthorizedHandler?.();
}
return Promise.reject(error);
},
);
Two design details:
- The comment documents the 403 decision right where someone would "fix" it later.
- The handler is injected as a callback. The API client stays a plain module with no React or query-client imports; the provider registers what "session is dead" means (clear the cached user, so the gate shows the login screen).
3. 403s are shown where they happen, with the backend's words
Since the interceptor ignores 403, each place decides how to show it:
- a page load that gets 403 → a full "Access denied" screen,
- a single action that gets 403 → an error toast or an inline error card.
In both cases, the message is the backend's own message, because it already explains the real reason ("Application 'billing' is disabled", "You do not have access to …"). One small helper extracts it reliably:
export function apiError(error: unknown): string {
if (!axios.isAxiosError(error)) return error instanceof Error ? error.message : FALLBACK;
const message = (error.response?.data as { message?: unknown } | undefined)?.message;
// NestJS validation errors return message as a string OR an array of strings
if (Array.isArray(message)) return message.filter((m) => typeof m === 'string').join('. ');
if (typeof message === 'string' && message.length > 0) return message;
if (!error.response) return 'Cannot reach the API. Check that it is running.';
return FALLBACK;
}
It handles the details of real APIs: messages as arrays (validation errors), missing messages, and no response at all (network down), which gets its own, more useful message.
4. Route protection: a client-side gate with four states
Because middleware can't see the cookies, protection is a component that wraps every admin page and asks the backend "who am I?":
export function AuthGate({ children }: { children: React.ReactNode }) {
const { data, isPending, isError, error } = useMe();
if (isPending) return <FullPageSkeleton />;
// 403 is a dead end, never a login prompt: the session works, the answer
// is still no. Show the backend's real reason.
if (isStatus(error, 403)) return <AccessDeniedScreen message={apiError(error)} />;
// `data` is null when the interceptor caught a 401 mid-session
if (isError || !data?.authenticated) return <LoginScreen />;
if (!data.user.isRootAdmin) return <NotAdminScreen user={data.user} />;
return <AuthContextProvider user={data.user}>{children}</AuthContextProvider>;
}
Four states, four screens, nothing in between:
| State | Screen |
|---|---|
| checking | a skeleton of the real layout, never a login flash |
| 403 | Access denied, with the backend's reason, and no login button |
| 401 / not authenticated | Login |
| authenticated but not Root Admin | a polite "this panel is for Root Admins" page, showing who they're logged in as |
| authenticated Root Admin | the app |
The skeleton matters more than it seems: showing the login screen for 200 ms while checking, then jumping to the app, looks broken and makes people click "log in" for no reason.
5. Login is a redirect, not an API call
Logging in isn't an axios request. It's a full browser redirect to the backend's login route, with a returnTo address:
window.location.href = `${config.apiUrl}/auth/login?returnTo=${encodeURIComponent(returnUrl)}`;
The login flow involves the company's identity provider and sets HttpOnly cookies on the backend's origin, which an XHR can't do. After a successful login, the browser comes back to returnTo, the gate asks "who am I?" again, and now gets a user.
6. Is client-side protection safe?
Yes, as long as you're clear about what it protects. The gate decides what to render. The data is protected by the backend: every API call is checked there, and without a valid session the backend returns nothing. A user who bypassed the gate in their browser would see empty screens and errors, not data. The frontend gate is UX; the backend is security.
Lessons learned
- 401 and 403 ask for opposite reactions. 401: go log in. 403: show why, and stop.
- Never send a user with a valid session to a login screen. It can't fix authorization and creates loops.
- Don't retry 401s blindly. If the backend already refreshes tokens, a 401 means the session is over.
- Use the backend's error message. It knows the real reason; parse it carefully (strings, arrays, no response).
- Keep the HTTP client free of UI. Inject "what happens on 401" as a callback.
- Know where your cookies live. Cookies on another origin are invisible to middleware, so protect routes with a gate that asks the backend.
- Render explicit states. Loading, denied, logged out, not allowed, allowed: each gets its own screen.
- Client-side gates are UX; the API is security. Never put the real protection in the browser.
FAQ
What is the difference between 401 and 403?
401 means the server doesn't know who you are (missing, expired or invalid credentials), so logging in can fix it. 403 means the server knows who you are and refuses anyway, so logging in again won't help.
Should my frontend log out the user on a 403 error?
No. A 403 means the session is valid but the action or resource isn't allowed. Show the reason instead. Only a 401 (or an explicit session-revoked response) should end the session in the UI.
Why can't Next.js middleware read my auth cookies?
If the cookies are set by a backend on a different origin, they belong to that origin. The browser only sends them to the backend, so the Next.js server never receives them.
Is client-side route protection secure?
It's fine for deciding what to display, as long as every API endpoint is protected on the server. The server must reject unauthorized requests; the client gate only improves the experience.
Facing a similar problem?
I help teams fix performance, backend and reliability issues in production.
Let's talk