← All posts
Frontend8 min readProject: Auth admin panel

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.

#Authentication#HTTP Status Codes#React#Next.js#Axios#Security

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

Next.jsReactTypeScriptAxiosTanStack Query

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:

  1. a user with a perfectly valid session opens something they're not allowed to see,
  2. the server answers 403,
  3. the app logs them out and shows the login screen,
  4. they log in again, successfully, because their identity is fine,
  5. 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

StatusMeaningIs the session OK?Will logging in again help?
401 Unauthorized"I don't know who you are" (no session, expired, revoked)noyes
403 Forbidden"I know who you are, and the answer is no"yesno

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:

StateScreen
checkinga skeleton of the real layout, never a login flash
403Access denied, with the backend's reason, and no login button
401 / not authenticatedLogin
authenticated but not Root Admina polite "this panel is for Root Admins" page, showing who they're logged in as
authenticated Root Adminthe 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
Welcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have fun

Componentity

Thanks for visiting my portfolio. Every project here reflects my passion for building and learning. If you’re interested in working together, have a question, or just want to connect—don’t hesitate to reach out. I'm always open to meaningful conversations and new ideas.

© 2026 Zakariya Zouazou. All Rights Reserved.

Contact Us