AuthGENERALSIMPLIFIED

Authorization-Aware UI

Render what the user can actually do, so the interface is honest — then let the server refuse the request anyway, and stop leaking the existence of what they cannot see.

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

How do I show each user an interface that matches their permissions, without ever mistaking that for permission?

The user intent

A person opens a shared workspace where they are a viewer. They want to read, comment where they are allowed, and not be offered a Delete button that punishes them with an error for believing it.

The obvious build

Fetch everything, render everything, and wrap the privileged parts in a permission check. {can('delete') && <DeleteButton/>} reads well, is easy to test, and means only the right people see the button.

Why it breaks

It means only the right people see the button. The endpoint behind it is unchanged, and its path is in the bundle that every viewer downloaded (The Browser Security Model).

How it breaks in a real browser
  • It means only the right people see the button. The endpoint behind it is unchanged, and its path is in the bundle that every viewer downloaded (The Browser Security Model).
  • "Fetch everything, render some of it" put the data on the wire. A viewer filtering a list client-side has already received the rows they are not allowed to see, and they are in the Network panel, in memory, and in any error report that captures state (Session Replay and the Privacy It Costs).
  • The check runs against a permission the client decided it had, which was derived from a payload the client can edit. Flipping it in devtools reveals the UI — which is only a real problem because teams then treat what the UI reveals as sensitive.
  • Rendering nothing is ambiguous. The user sees an interface with a hole in it, cannot tell whether the feature is missing, still loading, or refused, and files a support ticket that nobody can reproduce.
  • The check is written once per call site, so the twelfth one is subtly different, and the one added in a rush next quarter has no check at all — while the server, if it is doing its job, never noticed the difference (Drawing Component Boundaries).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • There are two decisions and they only look like one. What to render is a client decision, made for honesty and usability, and it is allowed to be wrong. What is permitted is a server decision, made per request, per object, and it is the only one that has any force (Where Authorization Must Live in Security Engineering).
  • The client's decision should be driven by data the server sends — a capabilities list attached to the resource — rather than by a role the client interprets. The server already knows what this user may do with this record; having it say so removes an entire class of drift (Server State Is Not Your State).
  • Per-object permissions are the part role checks miss. "Editor" is not a property of a user, it is a property of a user *and a document*, and a UI that caches a global role will offer Edit on the one document where they are not (Object-Level Authorization in Backend Engineering).
  • Hiding is not the only rendering choice. Omit, disable with a reason, show read-only, or show with a path to request access — these are different messages, and choosing between them is a design decision with real consequences for discoverability.
  • Existence is information. Whether a record, a route, a workspace or a user exists can be sensitive on its own, and a client that receives it in order to hide it has already leaked it. The remedy is server-side filtering, not client-side concealment (Broken Access Control (IDOR / BOLA) in Security Engineering).
  • A 404 where a 403 would be truthful is a deliberate technique for the same reason: it withholds existence. It has to be applied by the server consistently, and the client must render it as "not found" without helpfully speculating (Status Codes Clients Can Branch On in API Design).

What this makes the browser do

And which of it is avoidable.

  • Rendering and then hiding still costs the browser the work. An element with display: none was still styled to discover that, and a component that renders and then unmounts did the reconciliation anyway (Style Invalidation).
  • Data fetched to be filtered out costs the full transfer, the parse, and the memory to hold it — the most expensive way to not show something (The Real Cost of JavaScript).
  • Permission-driven layout changes are layout changes. A toolbar whose buttons appear after a capabilities response arrives reflows what is next to it, which is a content shift the user feels (Visual Stability).
  • Conditional rendering that changes the element type rather than an attribute forces the browser to build new nodes, and takes focus with it if the removed node had focus (Node Identity Across Updates).

Two decisions that look like one

Say the two sentences separately and the confusion becomes hard to sustain. "This user should not see a Delete button" is a design decision about an interface. "This user may not delete this record" is a policy decision about a system. They usually agree, they are made in different places, and only one of them is enforced.

The failure is not that people do not know this. It is that the code for both looks identical — a conditional on a permission — so the mental distinction erodes under deadline. Keeping the server's check visible in the client's design, by making a 403 a real UI state you have styled and tested, is the practical way to keep the two ideas separate.

  • The client's job is an honest interface. It is allowed to be wrong, and it will be, whenever permissions change under it.
  • The server's job is the decision. It runs on every request, including the ones no UI produced (Where the Check Belongs in Backend Engineering).
  • The arrow from deny back to render is the one people forget: a refusal is information the UI should use, not just an error to display.
The affordance path and the authority path
honestyclick, shortcut, console, curlthe only gate that holdspermittedrefusedcorrect the affordanceServer sends capabilities with the resourceClient renders matching affordancesUser acts (or bypasses the UI entirely)HTTP requestServer authorizes: this user, this object, this actionAction performed403 / 404 — a designed UI state
UserLLMAgentToolDataDecisionHumanGuardrail

Hide, disable, explain, or omit

Once you accept that this is a design decision, there is a real choice to make and four common answers with different costs. The right one depends on whether the user could reasonably expect the control to exist, and on whether the existence of the thing behind it is itself sensitive.

The rendering technique matters less than the choice, but it is not free: swapping an element type rather than toggling an attribute rebuilds nodes and can drop focus, and a control that appears late moves whatever is beside it.

What each permission-driven rendering change costs the browser
ChangestylelayoutpaintcompositeWhy
Toggle `aria-disabled` and a description on an existing buttonyesnomaybenoAn attribute change re-runs style for that element; layout is untouched because geometry does not change. Paint only if the disabled state alters colours.
Toggle `hidden` / `display: none` on a controlyesyesyesyesThe box leaves or enters flow, so siblings move. This is the version users feel as a jump when capabilities arrive late (Visual Stability).
Replace a button element with a static text labelyesyesyesyesNew nodes, new intrinsic sizes, and focus is lost if the removed node held it (Node Identity Across Updates).
Toggle `visibility: hidden` on a controlyesnoyesmaybeThe box keeps its space, so nothing shifts — but it stays in the layout tree and, critically, is removed from the accessibility tree too, so it is not a way to keep it announced.
Render a whole permission-dependent region after a separate capabilities fetchyesyesyesyesA full insertion into flow, late, after the user has begun reading. Reserve the space or attach capabilities to the original response instead.

caveat Whether any of these are noticeable depends entirely on what else is on the page and where the element sits in the flow: a control in a fixed-size toolbar can be toggled cheaply, while the same change in a text column moves everything below it. Profile the actual page rather than trusting the row (The Cost of a Change).

This user cannot perform this action. What should the interface do?

Which rendering is honest without being either confusing or disclosing?

Omit entirely

when The feature is irrelevant to this user's role and its existence is not a secret — an admin section for a normal user.

cost Users cannot discover a capability they might legitimately want, and support conversations start with "I do not see that menu".

Show disabled, with a reason

when The user could reasonably expect the control, and knowing why it is unavailable is useful — "Only workspace admins can delete".

cost It confirms the feature and the object exist. Needs aria-disabled and an associated description to be accessible at all (The Rules of ARIA).

Show read-only

when The user may see the data but not change it — the common case for a viewer role.

cost Two renderings of the same view to build and keep in sync, and a real risk that a stale capability leaves an editable field that will 403 on save.

Show with a request-access path

when Access is grantable and asking is a normal part of the workflow.

cost A request flow, notifications, and a disclosure decision — you have told them the resource exists and named it.

Return not-found from the server

when The existence of the object is itself sensitive.

cost Must be applied consistently by the server across every endpoint, search, and typeahead, or one inconsistent response reveals what the rest concealed (API Anti-Patterns Field Guide in API Design).

Do not leak what they cannot see

The disclosure bugs in this area are rarely in the main list view, because that is the one everybody reviews. They are in the surfaces built as conveniences: the mention picker, the global search, the "recently viewed" strip, the breadcrumb that resolves a parent id, the error message that helpfully names the object, and the shared cache that answered a second user with the first user's response.

The rule that catches all of them is the same one: the server filters, and the client renders what it received. A client that receives data in order to hide it has already lost, and no amount of care in the component tree recovers it (Server State Is Not Your State).

  • Search, typeahead and mention pickers are list endpoints. Filter them server-side with the same rules as the list they shadow.
  • Error text is a disclosure surface: "not found" rather than the object's name, when existence is sensitive.
  • Shared or CDN caches must not hold per-user responses. A permission-scoped response needs a cache key that includes the caller, or no shared cache at all (Caching as a Contract Clause in API Design).
  • Server-rendered HTML embeds state. Anything serialised into the document for hydration is as visible as a response body (Hydration).
  • Prefetching a route the user may not open still fetches its data. Prefetch on intent, not on render, when the data is permission-scoped (Route Loading Boundaries).
A list a viewer should only partly see
Filter on the client
const all = await api.get('/documents')          // returns every document
const visible = all.filter((d) => can(user, 'read', d))
return <List items={visible} />
// Titles, authors, ids and bodies of every hidden document
// are in the response, in memory, and in any captured state.
Filter on the server; render capabilities
const { items } = await api.get('/documents')      // already scoped to the caller
return <List items={items} render={(d) => (
  <Row title={d.title}
       onDelete={d.can.delete ? deleteDoc : undefined}
       deleteReason={d.can.delete ? undefined : d.can.deleteReason} />
)} />
// The client never held a row it may not show, and each row
// carries the server's own answer about what may be done to it.

The first is a display change over data that has already been disclosed — the Network panel, the memory heap and any error report all contain the rows the interface hid. The second makes the response itself the boundary, so the client cannot leak what it never received, and per-object capabilities travel with the object instead of being inferred from a cached role.

How to build it

Most important first.

  • Have the server send capabilities with the resource, and render from those. { id, title, can: { edit: false, delete: false } } is a contract that keeps the client honest by construction (How API Shape Drives UI Complexity).
  • Never fetch what the user may not see. If the API returns rows a viewer cannot read, that is a server bug and the client cannot fix it — client-side filtering is a display change over data that has already been disclosed.
  • Centralise the rendering decision in one component or hook so that the twelfth call site is the same as the first, and an audit is a search for one name (What a Component Owes Its Caller).
  • Prefer explaining to hiding when the user could reasonably expect the control to exist. A disabled button with "Only workspace admins can delete" teaches; a missing button confuses (Accessible Component Patterns).
  • Design the 403 as a first-class UI state with a message and a next step, not an error toast. It is the outcome the whole gate was allowed to be wrong about (Loading, Error, Empty — The States You Did Not Render).
  • Re-render on permission change. If capabilities arrive with each resource read, an invalidation after a role change updates the UI for free (Query Keys and Invalidation).
  • Test the enforcement separately from the rendering. A test that asserts the button is hidden proves nothing about the endpoint; the endpoint needs its own test (End-to-End Testing).

Keyboard, focus, semantics, announcement

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

  • Disabled and hidden are different announcements. A disabled button is skipped by keyboard navigation and may be unreachable for a screen-reader user to even discover; aria-disabled="true" on a focusable control keeps it discoverable and announced as unavailable (The Rules of ARIA).
  • If a control is unavailable, the reason must be programmatically available, not only in a tooltip on hover. Associate it with aria-describedby so it is announced with the control (Semantics Before ARIA).
  • When permissions change what is on screen, announce it. Controls silently appearing or disappearing under a screen-reader user mid-session is disorienting and reads as a broken page (Live Regions and Announcement).
  • If a control that had focus is removed because permission was lost, move focus somewhere sensible first. Focus on a detached node falls to the document body with no announcement at all (Focus Management).
  • A 403 state must be announced and focusable, exactly like a form error: the user needs to know the action failed and why, without a visual scan (Errors People Can Actually Perceive).
  • Never convey "unavailable" by colour or opacity alone. Reduced contrast on a disabled control is already borderline; without text it carries no meaning for anyone not seeing it (Contrast, Colour and Motion).

What can go wrong

Failure modes
  • Client-side filtering of a privileged list — the classic disclosure, invisible in the interface and plain in the Network panel.
  • A global role cached at login, so per-object permissions are wrong for exactly the objects that differ from the norm.
  • The mitigation failing: capabilities fetched separately from the resource, arriving later, and producing a flash where every control is briefly enabled, briefly disabled, or both (Loading, Error, Empty — The States You Did Not Render).
  • A hidden control whose keyboard shortcut still works, or whose route is still reachable by typing the URL, because only the rendering path was gated (Client-Side Routing).
  • Error messages that leak: "You cannot edit Q4 Layoff Plan" tells a user the document exists and what it is called. The name is the leak.
  • Autocomplete, search suggestions and mention pickers backed by an unfiltered index — the most common place a "hidden" record surfaces, because nobody thinks of a typeahead as a list endpoint.
  • Feature flags used as authorization. A flag decides whether a feature exists for a cohort; it never decides whether this user may act on this record (Feature Flags in the Client).
What can arrive out of order
  • Capabilities and resource arriving separately: the resource renders with stale or absent permissions and the controls correct themselves a moment later, under the user's cursor.
  • A permission revoked server-side while the client holds a rendered gate. The client finds out on the next rejection, which may be after the user has already tried.
  • A role change made in another tab or by an administrator elsewhere, with no invalidation reaching this client until something refetches (Auth Across Tabs).
Security
  • The browser enforces nothing here. Conditional rendering, disabled, hidden, CSS, and route guards are all client-side presentation, and the client is under the user's control (The Same-Origin Policy).
  • The server must authorize every request independently, including the ones only a hidden control would send, and including the object-level check that role-based middleware routinely skips (Authorization in Backends in Backend Engineering).
  • Anything the client received is disclosed, whether it was rendered or not: response bodies, embedded state in server-rendered HTML, prefetched routes, and the payload of any token that reached the browser (JWT — What It Is and What It Costs in Security Engineering).
  • Non-existence and non-permission are different disclosures, and which one you return is a policy decision that must be made by the server and applied consistently — including in search, autocomplete and error text (Least Privilege in Security Engineering).
  • A hidden admin route is not a secret. It is in the route table in the bundle, and enumerating it takes seconds (Client-Side Routing).
  • Where an interface acts on behalf of a model or agent, the same rule applies with less margin: a suggested action is not an authorized one (A Suggestion Is Not an Authorization).
Misreads
  • "Hidden means secure." Hidden means hidden. The action is an HTTP request and the request does not care what was rendered.
  • "We check permissions on the client and the server, so we are covered twice." You are covered once, by the server. The client check is a usability feature that happens to look like a control.
  • "disabled prevents the action." It prevents the click. The handler can be invoked, the attribute can be removed in devtools, and the endpoint can be called without any of this.
  • "Returning 404 instead of 403 is security through obscurity." Withholding existence is a legitimate policy when existence is sensitive; the mistake is applying it inconsistently, so that timing or a different endpoint reveals what the 404 concealed.
  • "The user can only see their own data because the UI filters by user id." Then the filter is a query parameter, and query parameters are typed by users (URL Parameters).
  • "A feature flag can gate a privileged feature." A flag controls rollout. It is evaluated on the client, it is visible in the bundle, and it is not an authorization boundary (Feature Flags in the Client).

Measuring it, and what changes in the field

How you would see this
  • Read the response bodies, not the screen. Anything present in the payload and absent from the UI is a disclosure you shipped (Debugging the Network).
  • Call the endpoint behind every privileged control directly with a low-privilege session. The expected result is 403 or 404, consistently, on every one (End-to-End Testing).
  • Search the bundle for privileged route paths and endpoint strings. If finding them is easy for you, it is easy for anyone (Bundle Analysis).
  • Track 403 rate by control in error tracking. A control that produces regular 403s is a rendering decision that disagrees with the server, which is a bug in the UI even though the server behaved correctly (Frontend Error Tracking).
Slow device, slow network, large data, old tab
  • On a slow network, a separate capabilities request lands late and every gated control flickers. Attaching capabilities to the resource removes the flicker with no extra round trip.
  • With large lists, per-row permission data adds up; a compact per-row capability set is worth designing rather than sending a policy document per row (List Virtualization).
  • In a long-lived tab, cached permissions age. A role revoked an hour ago is still rendering an enabled control until something invalidates it (Long-Lived Clients and Version Skew).
  • Under server rendering, the permission decision happens on the server for the first paint and on the client afterwards. If they disagree, hydration mismatches — and one of the two was rendered from stale data (Hydration Mismatch).
What this costs
  • Capabilities attached to every resource make payloads larger and couple the response shape to the UI's needs. They buy per-object correctness that no client-side role check can reach.
  • Explaining rather than hiding makes the product more discoverable and also advertises what exists. For some products that is good onboarding; for others the existence is the sensitive part, and you cannot have both.
  • Server-side filtering is the only correct answer for data the user may not see, and it makes caching harder: a response is now per-user, so a shared cache key is wrong (The Client Cache Model).
  • Centralising the gate is more indirection than an inline check, and it is what makes an audit a search for one component name rather than a reading of every file.

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.

  • GENERALThat rendering is not enforcement holds for every browser, framework and rendering strategy, because it follows from the client running on hardware the user controls. Server-rendered UIs are no exception: the markup is a decision about display, and the endpoint behind it still needs its own check.
  • SIMPLIFIEDCapabilities are shown here as booleans per action. Real authorization models carry conditions, ownership, delegation, time bounds and hierarchy, which is exactly why the client should render a server-computed answer rather than evaluate the policy itself (Authorization Models in Security Engineering).

Where the depth lives

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

Domains that do not exist yet
  • Software Design — "render from a server-computed capability" is the same move as "parse, do not validate": you replace a decision the client keeps re-deriving with a value that is already correct by the time it arrives.