AuthGENERALSIMPLIFIED

What the Frontend Is Responsible For in Auth

Four jobs — represent identity, send credentials safely, survive expiry, render authorization-aware UI — and one job that is never yours: enforcement.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

If the server is the only thing that can enforce anything, what is the frontend actually responsible for in authentication?

The user intent

A person wants to get into the product and do their work. They expect to be recognised, to stay recognised for as long as is reasonable, to be told plainly when they are not, and never to be shown a control that turns out to be a lie.

The obvious build

Log in, keep the token somewhere, decode it to read the user's role, and render the admin panel when user.role === "admin". The server checks tokens too, presumably, so between the two of us it is covered.

Why it breaks

The role check is a rendering decision that reads like a security control. The admin endpoint it hides is still on the network, still documented by the bundle that calls it, and still one fetch from a console away.

How it breaks in a real browser
  • The role check is a rendering decision that reads like a security control. The admin endpoint it hides is still on the network, still documented by the bundle that calls it, and still one fetch from a console away.
  • Everything the decision depends on is editable. The token is in storage the user can open, the check runs in code the user can breakpoint, and the state it produces is in memory a devtools extension can write to.
  • Nothing in this design has a state for "we have not asked yet". The first render happens before the identity probe resolves, so a returning user watches the logged-out header paint and then swap (Loading, Error, Empty — The States You Did Not Render).
  • Nothing handles the credential going stale. The session expires while a form is half filled and the user meets a wall of failed requests with no explanation and no way back except a manual reload (Session Expiry and the Refresh Race).
  • The role was read once at login and never again. A permission revoked ten minutes ago is still rendering an enabled button, and the only thing that corrects it is a server rejection nobody wrote a handler for.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Job one: represent identity state. Not a boolean. At minimum: unknown (no answer yet), anonymous, authenticated, and expired — which is distinct from anonymous because it has a destination to return to. Most flash-of-wrong-UI bugs are a missing unknown (The Seven Kinds of State).
  • Job two: send credentials safely. Either the browser attaches them for you, because they are in a cookie whose attributes match the request, or your code attaches them explicitly on a header. The two have opposite exposure profiles and the choice is a real trade-off (Cookies vs Script-Readable Tokens).
  • Job three: handle expiry. Credentials have a lifetime. The UI has to notice the transition, refresh once rather than five times, and preserve whatever the user was in the middle of (Session Expiry and the Refresh Race).
  • Job four: render authorization-aware UI. Show people what they can actually do, so the interface is honest — and never confuse that with permitting it (Authorization-Aware UI).
  • And the job that is not yours: enforcement. The server authenticates and authorizes every request independently, on its own copy of the truth, as though no client code exists. It does, in the sense that anyone can write their own (Where Authorization Must Live in Security Engineering).
  • A signature you verified in the browser proves the token was not tampered with in transit. It does not make the claims inside it a basis for an authorization decision, because the whole verification — key, algorithm, branch — runs on the attacker's machine when the attacker is the user (JWT — What It Is and What It Costs in Security Engineering).

What this makes the browser do

And which of it is avoidable.

  • Matching the cookie jar against every outgoing request: scheme, host, Domain, Path, SameSite and the request's initiator all participate, and the browser does it whether or not your code knows a request is authenticated.
  • For a token sent on an Authorization header cross-origin, a CORS preflight — an extra round trip before the real request, because a custom header is not a simple one (CORS).
  • Re-rendering whatever subscribes to identity every time it changes. An identity value at the root of the tree that changes identity on each probe re-renders the entire application (What a Component Costs to Render).
  • Reading storage synchronously on the main thread during boot, if that is where the credential lives — small, but it is on the critical path to first interaction (localStorage and sessionStorage).

Four jobs on one side of a boundary

Draw the line first, because everything else is a consequence of where it sits. On one side is code you shipped, running in a process the user owns, that they can read, pause, edit and replace. On the other is code running on a machine you control, which is the only place a decision can actually be made. Auth work in the browser is entirely on the first side.

That sounds like it leaves the frontend with nothing to do, and it is the reason people under-invest here. In fact it leaves four jobs, and all four are user-visible. Getting them right is what separates a product that feels like it knows who you are from one that logs you out mid-sentence and shows you a button that returns an error.

  • Represent identity state — including the state before you know, which is where the flash of wrong UI comes from.
  • Send credentials safely — automatically by the browser, or explicitly by your code, with different exposure either way (Cookies vs Script-Readable Tokens).
  • Handle expiry — notice it, refresh once, and keep the user's work (Session Expiry and the Refresh Race).
  • Render authorization-aware UI — an honest interface, never a gate (Authorization-Aware UI).
  • Never enforce — the server re-decides every request from scratch (Authorization in Backends in Backend Engineering).
Where the decision is actually made
decides rendering onlyrequestevery request, independentlyalloweddeniedcorrects the clientUserUI: render what they can doCredential transport (cookie or header)Trust boundaryServer authenticates the requestServer authorizes the actionResource401 / 403 back to the UIIdentity state (unknown / anon / auth / expired)
UserLLMAgentToolDataDecisionHumanGuardrail

Identity is a state machine, not a boolean

A boolean has two states and identity has at least four, so a boolean has to encode the other two as "false" — which is why the logged-out header paints for returning users and why an expired session is indistinguishable from never having logged in. The unknown state in particular is not an edge case: it is the state every single page load starts in.

Writing it as a discriminated union makes the missing branches into compile errors instead of visual bugs, and it forces the question that boolean code never asks: what should this component render while we do not know? Usually the answer is a stable skeleton of the right shape, which also avoids shifting content once the answer arrives (Visual Stability).

The same component, two identity models
Boolean
const isAdmin = user?.role === 'admin'
return isAdmin ? <DeleteButton /> : null
State machine plus a server-owned capability
if (identity.status === 'unknown') return <ActionSkeleton />
if (identity.status === 'expired') return <ReauthPrompt returnTo={identity.returnTo} />
return canRender(identity, 'orders:delete')
  ? <DeleteButton />          // affordance only
  : null                      // the endpoint still 403s either way

The first renders null for three different reasons — still loading, not permitted, and session over — and the user cannot tell which, so neither can the person debugging it. The second makes each reason a separate branch with a separate remedy, and neither version is a security control: the comment in the second one is the load-bearing part.

The states, and the transitions the server drives
1type Identity =
2 | { status: 'unknown' } // boot; nobody has asked yet
3 | { status: 'anonymous' } // asked, and there is no session
4 | { status: 'authenticated'; user: User } // asked, and there is one
5 | { status: 'expired'; returnTo: string } // there was one; it ended mid-session
6
7// The only transitions that exist, and who causes them:
8// unknown -> anonymous | authenticated (the identity probe answers)
9// anonymous -> authenticated (a successful login response)
10// authenticated -> expired (a 401 on a request we believed would work)
11// expired -> authenticated (a successful refresh or re-login)
12// any -> anonymous (an explicit logout, here or in another tab)
13//
14// Note what is absent: no transition is caused by client-side logic
15// inspecting a token. Every one of them is the server answering.
16
17function canRender(identity: Identity, capability: string): boolean {
18 // A rendering decision. It is allowed to be wrong; the server will correct it.
19 return identity.status === 'authenticated'
20 && identity.user.capabilities.includes(capability)
21}

The returnTo on expired is the difference between "log in again and land where you were" and "log in again and start over from the home page". It is a field, not a feature.

What the client does, what only the server can

The most useful artifact in this lesson is a two-column list, because almost every auth incident is a row where somebody wrote the client half and assumed the server half existed. Reading it as a checklist against your own code takes an afternoon and is the highest-yield auth review available to a frontend team.

The right-hand column is not advice for the backend team. It is what your frontend is allowed to assume, which means it is also the list of things you should verify by calling the endpoint yourself with the UI out of the picture (How API Shape Drives UI Complexity).

ConcernThe frontend's jobWhat only the server can doWhat happens when they are confused
Who the user isHold the answer the server gave and render from itValidate the credential on every request against its own storeA forged or edited client identity renders a full admin UI that quietly 403s everywhere
What they may doRender matching affordances; disable or omit the restDecide per request, per object, before doing the workA hidden endpoint reachable with curl — the archetypal broken access control
Credential storageChoose an exposure profile and defend itChoose the lifetime, the rotation and the revocationA long-lived credential in a script-readable place, exfiltrated by one injected script
Session endNotice a 401, refresh once, preserve the user's workInvalidate the session so the old credential stops working everywhereA "logged out" UI whose token still works, or a valid session the UI has thrown away
Which records existRender only what the response containedFilter the response by the caller's permissionsClient-side filtering of a full list — the data was already on the wire (Authorization-Aware UI)
Rate and abuse limitsDisable the button while a request is in flightEnforce the actual limit and reject the excessA disabled button as the only limit, defeated by holding Enter (Rate Limiting in Backend Engineering)
Audit trailSend a correlation id so the two halves can be joinedRecord who did what, from the authenticated identity, not from the payloadAn audit log that records whatever the client claimed it was

How to build it

Most important first.

  • Model identity as an explicit state machine with an unknown state, and render a deliberate placeholder for it. "We do not know yet" is a UI state that deserves a design, not a default of false (Who Owns This State?).
  • Put the transport in exactly one place. One module decides how credentials are attached, how a 401 is interpreted, and when a refresh happens. Auth logic scattered across call sites is how three of them end up with different retry behaviour (The Life of a Fetch).
  • Treat 401 and 403 as protocol, not as errors to log. 401 means "no valid credential — re-authenticate"; 403 means "valid credential, not allowed — do not retry, and correct the UI that offered this" (Status Codes Clients Can Branch On in API Design).
  • Derive rendered permissions from something the server sends and refreshes, not from a claim decoded once at login. A capabilities payload the API returns with the resource is far harder to get wrong than a role string cached at boot (Server State Is Not Your State).
  • Assume every gate you render will be bypassed, and design the server rejection to be a good user experience. If a 403 is the only thing standing between a user and an action, it should produce a clear message, not a stack trace (Loading, Error, Empty — The States You Did Not Render).
  • Keep the same model on the server-rendered path. If the first HTML is rendered with a session, the client must not re-decide identity from scratch and produce different markup (Hydration Mismatch).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Identity transitions are content changes without a page load, and screen-reader users get no notification unless you provide one. "Your session has ended" belongs in a live region, not only in a coloured banner (Live Regions and Announcement).
  • The unknown state must have an accessible name. A bare spinner announces nothing; role="status" with text such as "Checking your session" tells a non-visual user that waiting is expected rather than broken.
  • A redirect to a login screen must move focus to the new context — the heading or the first field. Focus left on a control that no longer exists strands keyboard and screen-reader users at the top of the document with no announcement that anything changed (Focus Management).
  • Auth errors are form errors and need the same treatment: programmatically associated, announced, and never conveyed by colour alone (Errors People Can Actually Perceive).
  • Never gate identity-critical UI behind hover or pointer-only affordances. A "sign out" that only appears on mouseover of an avatar is unreachable by keyboard (Keyboard Operability).

What can go wrong

Failure modes
  • Flash of authenticated UI: the app optimistically renders as logged in from a cached value, the probe comes back anonymous, and the user watches their own name disappear.
  • The mitigation failing: gating the whole app behind identity !== "unknown" means a failed identity probe — offline, a 500, a blocked request — leaves a permanent spinner with no error path and no way to log in.
  • A boolean isLoggedIn that is true because a token exists in storage, without anyone asking whether it is still valid. Presence is not validity.
  • Silent authorization drift: the client's idea of the user's permissions and the server's diverge after a role change, and only the second one is real.
  • Auth logic duplicated into a second client — a mobile web view, a widget, an embedded iframe — where one copy handles 401 and the other loops (Micro Frontends).
What can arrive out of order
  • The identity probe and the first data request go out together. If the data request wins and returns 401, an app that reacts by clearing identity can log out a user who was in fact logged in.
  • A login completing in another tab while this tab is mid-render: this tab's in-memory identity is anonymous and the cookie jar it shares says otherwise (Auth Across Tabs).
  • A permission change on the server while a client-side gate is already rendered. The client learns about it only when a request is rejected, which may be much later.
Security
  • What the browser genuinely enforces: origin isolation, so another site cannot read your DOM, storage or HttpOnly cookies; cookie attribute semantics; secure-context requirements; and whatever policy the server declared in headers (The Browser Security Model).
  • What the browser does not enforce: any rule of yours. Conditional rendering, disabled attributes, route guards and client-side role checks are all suggestions to a cooperating client, and the user is not obliged to run one (The Same-Origin Policy).
  • Everything shipped is public: endpoint paths, request shapes, feature flags, the names of roles, and every comment left in the bundle. Minification is not concealment (Third-Party Scripts and the Supply Chain).
  • An attacker who achieves script execution in your origin inherits everything your code can do — every fetch with your credentials attached, every read of anything script can read. That is why the storage decision matters and why XSS is the dominant frontend auth risk (Cross-Site Scripting, and XSS Defense by Output Context in Security Engineering).
  • Deep detail on attacker technique — token theft, replay, session fixation, hijacking — belongs to Security Engineering (Session Hijacking). This domain owns the browser behaviour and the client's obligations.
Misreads
  • "Hiding the button secures the action." It removes the affordance. The action is an HTTP request, and the request is still there (Broken Access Control (IDOR / BOLA) in Security Engineering).
  • "The token is signed, so I can trust its claims in the client." You can trust them enough to decide what to render. You cannot make an authorization decision with them, because your verification code is running on the machine you are trying to constrain.
  • "Auth is a backend concern, so the frontend does nothing." The frontend owns four jobs, and every one of them is visible to the user when it is done badly.
  • "A route guard is a security boundary." It is a redirect. The data behind the route arrives from an API that must reject the request on its own (Client-Side Routing).
  • "We are safe because the API needs a token." Every legitimate user has one. Authentication answers who; authorization answers what — they are different questions and the second is the one hidden buttons pretend to answer (Authentication vs Authorization in Backend Engineering).

Measuring it, and what changes in the field

How you would see this
  • The Network panel is the audit: call the endpoint behind a hidden control directly and confirm the server returns 403. If it returns 200, the button was the only control (Debugging the Network).
  • Rate of 401 responses by route in error tracking. A steady background rate is expiry working; a spike after a deploy is usually a credential-transport regression (Frontend Error Tracking).
  • Application panel: which cookies exist, with which attributes, and what is in Web Storage. This is also what an attacker sees, which is a useful thing to look at deliberately (Storage Security and Durability).
  • Field data on how long the identity probe blocks first interaction — it sits on the critical path for every returning user (Real User Monitoring).
Slow device, slow network, large data, old tab
  • On a slow network the gap between first paint and a resolved identity widens, so the flash you cannot reproduce locally is several hundred frames long for a real user.
  • In a tab left open overnight, the credential is almost certainly stale and the in-memory identity is almost certainly optimistic (Long-Lived Clients and Version Skew).
  • With several tabs open, identity is shared state with no single owner. A logout in one is not observed by the others unless you make it observable (Auth Across Tabs).
  • Under server rendering, identity exists before the client does, and the client must adopt it rather than recompute it (Server-Side Rendering).
What this costs
  • A four-state identity machine is more code than a boolean and every consumer has to handle every state. It buys you the absence of an entire bug class — the ones that only appear between boot and the probe resolving.
  • Blocking the first render until identity is known removes the flash and delays content for everyone, including anonymous visitors who needed neither. Splitting the page into identity-dependent and identity-independent regions costs layout work you would rather not do (Visual Stability).
  • Fetching capabilities from the server rather than decoding them from a token costs a request and a cache-invalidation story. It buys permissions that can change without a re-login (Query Keys and Invalidation).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe division of responsibility holds for every browser client regardless of framework or credential format: the client is untrusted code running on hardware the user controls, so only the server can decide. Native and mobile clients differ in exposure surface but not in this conclusion.
  • SIMPLIFIEDFour identity states are the teaching minimum. Real products often add "authenticated but not yet verified", "authenticated with a step-up requirement pending", and impersonation sessions, each of which is a distinct state with distinct rendering, not a flag on authenticated.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Software Design — modelling identity as an explicit state machine rather than a bag of booleans is the same technique applied to any lifecycle, and the reasons it pays off are not specific to auth.