skip to content

How would you divide access decisions between client route guards and server enforcement across many role-gated screens?

level: principalimportance: should knowfreq 50%

answer

  1. one owner for the policy
  2. server decides, client only offers
  3. ship the result, not the rules
  4. gate routes, not individual records
  5. forbidden is not signed-out

basics

~20 s

Policy gets one owner: the server. It hands the client a computed capability set that guards, menus and links read, so the app stops offering screens that would only fail — while the server authorizes every request.

solid answer

~50 s

The split I argue for is decision versus presentation. The server owns the policy and authorizes every request; the client's job is to avoid offering a screen that would only fail. So that this is not a second implementation of the rules, the server sends a **computed** capability set for the current user rather than the rules themselves, and guards, navigation menus and link rendering all read that one set. Client gating stays coarse — routes and actions, not individual records — because per-record decisions need data the app does not have before it navigates. Denial is then a designed state with three branches: no session sends the user to sign-in carrying their destination; a signed-in user lacking the role sees a forbidden screen, not the login form; an expired session re-establishes and retries. And because the capability set ages, a server rejection has to refresh it and re-evaluate the current route.

go deeper

for a junior

Hold on to the core rule: a guard decides what the app shows, and the server decides what the user may actually have. Both exist, and only one of them enforces.

for a middle

Explain why duplicating the rules in the client is worse than receiving a computed capability set, and why route-level gating cannot answer per-record questions.

for a senior

Design the three denial states as real screens, keep the intended destination through sign-in, and make a server refusal refresh the client's stale permission data.

for a principal

Own the boundary: one policy owner, a small computed payload across it, coarse client gating, and a verification practice where a hidden route counts as no evidence of protection.

## Start from what each side can actually guarantee Everything in the client — the route table, the guards, the permission data they read — is shipped to a machine the user controls. It can be read, edited, or skipped entirely by calling the endpoints directly. The server is the only party that can refuse. So the division is not "two layers of security"; it is: - **Server: the decision.** Authorize every request against the policy, per request, close to the data. - **Client: the offer.** Do not present a screen, link or control that would only fail, and make failure legible when it happens anyway. Saying this out loud matters, because a team that believes the guard is a control will eventually ship an endpoint with no check behind a well-guarded screen. ## One owner for the policy The temptation in a large app is to encode the rules in the client so guards can answer instantly. That produces two implementations of the same policy, and the client copy drifts on its own deployment schedule. The alternative is to move only the **result** across the boundary: 1. The server evaluates the policy for the current identity and returns a **capability set** — the coarse things this user may do or reach, named in the app's own vocabulary. 2. Guards, the navigation menu, and any conditional control read that one set. A screen is gated in exactly one place, and the menu can never disagree with the guard. 3. Adding a rule is a server change plus a capability name, not a rule re-implemented in two languages. | Approach | Policy copies | Drift risk | What it costs | |---|---|---|---| | Rules duplicated in the client | two | high — diverges every deploy | ongoing review burden, silent mismatches | | Server-computed capability set | one | low | a payload to keep small, and a refresh story | | No client gating at all | one | none | users offered screens that only fail | ## Keep client gating coarse Route-level gating works because the question is answerable before the navigation: may this identity reach this area at all. Per-record questions — may this user edit *this* document — are not answerable before the record is loaded, so pre-checking them means fetching to decide whether to fetch. The workable shape: - **Routes and areas**: gate with guards, using the capability set. - **Actions on a loaded record**: let the record's own response carry what the viewer may do with it, and shape the controls from that. - **Anything unresolved**: let the screen render and display the server's refusal as a designed state rather than guessing. ## Denial is a designed state, not an error Three refusals look identical in a naive implementation and must not be: | Situation | What the user needs | Common mistake | |---|---|---| | No session | sign-in, with their intended destination carried over | dropping the destination and landing them on a home screen | | Session, insufficient permission | a forbidden screen saying what is missing and who can grant it | bouncing them to sign-in they already passed | | Session expired mid-use | re-establish quietly, then retry the original navigation | treating it as forbidden and losing their work | Sending a signed-in user to a login form is the one that generates support tickets: it tells a user who did everything right that their identity is the problem, and it can loop when the sign-in screen's own guard sees a valid session and sends them back. ## Plan for staleness A capability set fetched at sign-in is a snapshot. Roles change, memberships are revoked, trials end. Since guards are not reactive, the app finds out when a request is refused — so make that refusal useful: - Treat a permission refusal from the server as a **signal**, not just an error: refresh the capability set, then re-evaluate the current route. - Give the set a modest lifetime and a refresh path, so a change becomes visible without a reload. - Accept deploy skew: a client that has not yet learned a new capability name should fail toward showing less, while the server is what actually decides. ## How you verify it - Test the **endpoints directly** with each identity. A passing route-guard test is not evidence that anything is protected. - In review, treat "the route is hidden" as no evidence at all; ask which server check covers the data. - Check the three denial branches as user journeys, including the destination surviving a sign-in. - Watch the rate of server permission refusals: a rising rate usually means the client's capability data has drifted from the policy, not that users are attacking anything. ## What interviewers listen for - That you separate decision from offer instead of describing "defence in depth". - That you refuse to duplicate the policy client-side, and can say what you ship instead. - That you distinguish unauthenticated from forbidden as user-facing states. - That staleness, refresh and verification all have concrete answers.

  • Why not re-implement the permission rules in the client for a faster answer?
    Because two implementations of one policy drift, and the client copy is never authoritative — it only ever produces confident wrong answers. Shipping a server-computed capability set keeps a single owner while still giving guards an instant local decision, and adding a rule stays a one-sided change.
  • How should the app react when the server refuses a request the client believed was allowed?
    Treat it as a signal that the capability data is stale: refresh it, re-evaluate the current route, and land the user on a forbidden or default screen if the refusal stands. Reporting it as a generic failure hides a drift problem and leaves the user staring at a screen they cannot use.
  • When is a per-record permission check worth doing in the client at all?
    After the record has loaded, to shape controls — hiding a destructive action the viewer cannot perform. As a pre-navigation gate it needs data the app has not fetched yet, so route gating stays coarse and the screen renders the server's refusal instead of guessing.

saying these in an interview costs you the question

  • Calls the client guard the access control for the feature
  • Duplicates the permission rules in the shipped bundle
  • Sends a forbidden but signed-in user back to the login form
  • Caches the capability set for the whole session with no refresh
  • Accepts a hidden route as proof the data is protected
  • Tries to gate individual records before their data is loaded