cf.completefrontend
My progress
SYSTEM DESIGN · #63

Design permission-based UI

The frontend side of authorization in a B2B app: what each user sees and can do depends on their role, their team, the resource, and the customer's plan. Showing the right buttons is the easy part; the real work is never trusting the client, choosing what the browser is told, deciding between hiding, disabling, and explaining, and keeping the UI honest when permissions change while the page is open.

The question

We are building a project tracker for companies, a bit like Linear or GitHub Issues. Each customer is an organization with members, teams, and projects. In each project a viewer can read and an editor can create and edit issues; an org admin can change settings and members; and some features only exist on the Business plan. Design how the frontend decides what each person sees and can do.

Assume about 20,000 organizations, from 3 people to 15,000 people each, about 300,000 daily users, a React single-page app with some server-rendered pages, and a REST API. Admins change roles at any time, and many users belong to more than one organization.

Real-life examples: GitHub organizations, teams, and repository roles, Google Drive sharing roles, Notion workspace and page permissions, Linear teams and workspace roles, Salesforce profiles and permission sets, Figma team and file access

Requirements: who can do what, and what the frontend owns

Authorization is the question "may this person do this thing to this resource?". It is different from authentication, which answers "who is this person?". Sign-in, sessions, and tokens are covered in the auth-flow guide; here we assume the user is signed in and the API knows who they are. This guide is about everything after that: why one user sees a red Delete button and another does not.

It sounds like a few if statements around buttons, which is why interviewers like it. The naive version checks user.role === 'admin' in forty components, flashes the Settings tab for half a second before hiding it, keeps showing Delete after an admin removed your access, and leaks the name of a private project through the navigation menu. A strong answer is about those details.

Questions to ask first

QuestionAnswer we assumeWhat it changes
Fixed roles or custom roles?Four built-in org roles (owner, admin, member, guest), editor and viewer roles per project, and custom roles on the Business planCustom roles kill any role === 'admin' check in the client; the UI must check capabilities instead
Do permissions depend on the resource?Yes: project roles, private projects, issue authors, archived projectsA global list is not enough; some checks need per-resource answers
Do paid plans gate features?Yes: audit log, SSO settings, custom roles, and more than 3 private projects need BusinessEntitlements become a second axis with a different UI: an upgrade path, not a dead end
How fast must a revoked role take effect in open tabs?API at once; UI within about 30 secondsWe need a push channel or a refetch on 403, not only a check at boot
Can users belong to many orgs?Yes, and they switch with an org switcherCapabilities are per org; switching must not flash the old org's UI
Does the server render some pages?Yes, settings and issue pages are server rendered for speedThe server must check before it sends HTML, not after hydration

Functional requirements

  • Every screen shows only the actions the current user may take, in the current org, on the current resource.
  • Features the user's plan lacks are visible with an upgrade path; features their role lacks show who to ask.
  • Pages the user may not open show a clear 403 page, or a 404 when even knowing the page exists would leak something.
  • Forms show read-only or hidden fields according to field-level permissions.
  • Admins manage roles in a permissions matrix and can preview the app as another role.
  • When an admin changes a role, open tabs of the affected user catch up without a reload.

Non-functional requirements

  • Security: the server enforces every rule. A user with DevTools open must not be able to do more than the API allows.
  • No flashes: forbidden UI is never painted and then removed. Allowed UI is not hidden for a frame and then shown.
  • Speed: one capabilities request per org at boot (or zero, if bootstrapped in the HTML), and checks in components are synchronous, well under 1 ms each.
  • Small and private: the client learns answers for this user, not the whole policy of the org.
  • Accessible: a disabled action tells keyboard and screen reader users why, not only mouse users.
  • Typed: a misspelled permission name fails the build, not production.
A wireframe of the issue screen as Maya, a member of Globex, an org on the Free plan, sees it. Top left, the org switcher shows globex; top right, her role: member. The sidebar lists Issues, Projects, Members, and Audit log with a Business badge; Settings is hidden. The main area shows the issue title, a status field she can edit, and a priority field that is read only. The toolbar has Edit and Assign enabled and Invite disabled, with the reason: only admins invite. Delete is hidden. Two notes name the treatments: Delete is hidden because only admins delete, and Invite is disabled with a reason reachable by keyboard.globex / web-app / ISS-2041org: globexrole: memberIssuesProjectsMembersAudit logBusiness badgeno SettingsExport fails on large CSVsstatus: editpriority: readEditAssignInviteonly admins inviteDelete: hiddenadmins onlyInvite: disabled,reason reachable
One screen, four treatments: enabled, disabled with a reason, read only, and hidden. The Audit log stays in the nav with a plan badge, because hiding a paid feature hides it from the people who might buy it.

What makes this hard

  • The client is not trusted. Everything in the browser can be read and changed. The UI can only reflect decisions the server makes and enforces.
  • Several axes meet on one button. Role, team, resource state, ownership, plan, and feature flags all decide one Delete button, and each axis wants a different message when it says no.
  • Permissions change while the page is open. An admin removes you from a project at 10:02; your tab loaded at 9:00.
  • Context changes. The same person is an admin in one org and a guest in another, one click apart.
  • Saying no is a design problem. Hiding, disabling, and explaining each fit different cases, and the wrong one either leaks data or hides a feature people would pay for.

Check your understanding · Exercise 1

Select all that apply: which answers to the scope questions change the client design the most? Select all that apply.

The golden rule: the server enforces, the client reflects

Start every answer to this question with one sentence: hiding a button is UX, not security. The browser runs code the user controls. They can open DevTools, flip a boolean in React state, edit the JavaScript, or skip the UI entirely and call the API with curl using their own session cookie. Anything the client decides, the user can undecide.

So the API checks every request on its own, as if no UI existed. The client's checks exist for three other reasons: to not show people things they cannot do (less confusion), to explain why (fewer support tickets), and to avoid wasted round trips. If every client check disappeared tomorrow, the product would get worse to use but no data would leak. That is the test of a correct design.

This is not a theoretical worry. Broken access control moved up from fifth to first place in the OWASP Top 10 for 2021, with the most occurrences in OWASP's data (over 318,000), and it stayed first in the 2025 edition.

OWASP's advice is blunt: access control only works in trusted server-side code, where the attacker cannot change the check. A common form is the insecure direct object reference (IDOR): the UI never shows a link to project 812, but the API happily returns GET /api/projects/812 to anyone who guesses the number, because the developer assumed the UI was the gate.

The failure path: a hidden action called anyway

Walk through it once, out loud, in the interview. Maya is a member, not an admin. The Delete project button is hidden for her. She opens the console and runs the request the button would have sent.

js
await fetch("/api/projects/812", {
  method: "DELETE",
  headers: { "X-CSRF-Token": document.querySelector("meta[name=csrf]").content },
});
// -> 403 Forbidden
// { "error": { "code": "forbidden", "required": "project.delete",
//   "reason": "role", "message": "Only admins can delete projects." } }
What Maya types in the console: the cookie goes with it, so the request is fully signed in.

The server loaded Maya's membership, asked its policy "can user 41 do project.delete on project 812?", got no, and returned 403 Forbidden before touching the database row. The CSRF token (see the csrf lesson) proves the request came from our page; it says nothing about whether Maya may do it. Authentication, CSRF protection, and authorization are three separate checks.

A sequence diagram of a hidden action forced from the console. Columns: Maya, console, API, authz, audit. The Delete button is hidden for Maya. She runs a DELETE request for project 812 from the console, with her session cookie and CSRF token. The API asks the authz service to check project.delete; authz answers deny, reason role. The API writes the denial to the audit log and replies 403 forbidden with the required capability. The project row is never touched.MayaconsoleAPIauthzauditno buttonrun fetchDELETE 812check deletedeny: rolelog denial403 forbiddenrow safe
The UI never offered the action, and it did not matter: the request is signed in and carries a valid CSRF token, yet the API still asks authz.check and stops it. The hidden button was only ever UX.

What the server side looks like

The frontend team often owns a backend for frontend (BFF) or at least reviews API code, so know the shape. One function decides, every handler calls it, and the same function produces the capability list the client receives later. One source of truth means the button and the API cannot disagree for long.

ts
app.delete("/api/projects/:id", async (req, res) => {
  const project = await projects.find(req.params.id, req.org.id);
  if (!project || !authz.check(req.user, "project.view", project).allowed)
    return res.status(404).json(notFound());
  const decision = authz.check(req.user, "project.delete", project);
  if (!decision.allowed) {
    audit.denied(req.user, "project.delete", project.id);
    return res.status(403).json(forbidden(decision));
  }
  await projects.remove(project.id);
  res.status(204).end();
});
Every write goes through the same check. The client never gets to skip it.

Three details matter. The lookup is scoped by req.org.id, and a project the user cannot even see gets the same 404 as a missing one, so an ID from another org or a private project reveals nothing. Denials are logged, because a burst of 403s from one account is a signal worth alerting on.

And the 403 body carries a machine-readable code, the required capability, and a reason, so the client can react precisely instead of showing a generic error. The api-contracts-and-errors lesson covers error envelopes in general.

Check your understanding · Exercise 2

Select all that apply: Maya removes the hidden attribute from the Delete project button in DevTools and clicks it. What should happen? Select all that apply.

Choosing the model: roles, capabilities, attributes, relationships, and plans

Before deciding what the client checks, decide what the system is made of. There are four classic ways to model authorization, plus one axis that is not authorization at all. Most real products end up mixing them, and the frontend design depends on which ones are in play.

Decision How should the system decide whether a user may do something?

RBAC: role-based access control

Each user gets a role per org: owner, admin, member, guest.

Each role maps to a fixed set of permissions. Checks ask "does this role allow it?".

Pros

  • Easy to explain to customers and to audit.
  • Small: one role name per user per org.

Cons

  • Coarse. "Members can edit issues, but only in projects they belong to" does not fit.
  • Customers ask for custom roles within months, and role names stop meaning anything fixed.

Capabilities (permissions) as the unit

Name every action: issue.create, project.delete, billing.manage.

Roles, built-in or custom, are just named bundles of capabilities. Code only ever checks capabilities.

Pros

  • Custom roles cost nothing: a new bundle.
  • A check reads like the product: can('issue.create').

Cons

  • Still says nothing about which resource, unless combined with the next two.
  • Needs discipline to keep names stable and documented.

ABAC: attribute-based rules

Rules read attributes of the user, the resource, and the context.

Example: an author may edit their own comment for 15 minutes; nobody may edit an archived project.

Pros

  • Expresses ownership, state, and time naturally.
  • Few rules cover many cases.

Cons

  • Answers depend on the resource, so a single global list cannot hold them.
  • Rules are code: harder to audit and to explain in a UI.

ReBAC: relationship-based (Zanzibar style)

Store tuples like project:812#editor@team:design and team:design#member@user:41.

A check walks the graph: is user 41 an editor of project 812 through any path?

Pros

  • Fits sharing, nested folders, and teams, like Google Drive or GitHub.
  • Scales: Google's Zanzibar paper reports millions of checks per second.

Cons

  • Needs a dedicated service and a new mental model.
  • Listing everything a user can access is a separate, harder query.

What we pick: Use capabilities as the only thing the UI checks, granted through roles (RBAC), refined per resource by ownership and state (ABAC) and by membership in teams and projects (ReBAC). Treat the plan as a separate axis. The UI does not care how the server computes the answer, only that answers are named capabilities.

Zanzibar is Google's global authorization system, described in a paper at USENIX ATC 2019. It stores relationships as tuples and answers checks for products like Drive, Calendar, and YouTube. The paper reports trillions of access control lists, millions of checks per second, a 95th percentile latency under 10 ms, and over 99.999 percent availability across three years.

Open-source systems inspired by the paper include SpiceDB (from AuthZed) and OpenFGA (created by the Auth0 team at Okta, a CNCF incubating project since October 2025). For our question, the point is not to build Zanzibar. It is that whichever engine the backend picks, it should expose the same simple answer to the client: allowed or not, per named capability.

One consequence reaches the frontend directly: lists. "Can user 41 see project 812?" is a cheap check, but "which projects can user 41 see?" is a different query, and it is the one every sidebar and search box needs. Relationship engines offer it as a separate API (OpenFGA calls it ListObjects, SpiceDB calls it LookupResources), and it is slower than a single check.

The client never runs it; it just asks for GET /projects and trusts that the list was filtered before pagination, so page 1 holds 25 visible projects, not 25 minus the hidden ones.

Three authorization models side by side, each answering whether user 41 may update an issue. RBAC: user 41 in org acme has the role member, which bundles capabilities including issue.update. ABAC: user attributes, id 41 and team design, and issue attributes, author 41 and open, feed a rule: the author may edit if the issue is not locked. ReBAC: user 41 is a member of team design, which is an editor of project 812, which contains the issue. A note at the bottom: every model ends in one answer, can issue.update, yes or no.RBACuser 41in org acmerole: memberbuilt-in or customcapabilitiesissue.update, ...ABACuser attrsid 41, team designissue attrsauthor 41, openruleauthor, not lockedReBACuser:41member ofteam:designeditor ofproject:812contains issueevery model ends in one answer:can issue.update? yes or no
The server may use any mix of these. The browser should not care: it receives the same yes or no per named capability either way.

Plans are a separate axis: entitlements

An entitlement is what the customer's plan includes: audit log, SSO, custom roles, 50 GB of storage, 3 private projects. It is not a permission. An admin on the Free plan has every permission for the audit log and still cannot open it. A member on Business has the entitlement but lacks the permission.

The two say no for different reasons, and the UI must say different things: "Upgrade to Business" to the first, "Ask an admin" to the second.

A third axis is the feature flag: is this feature even released to this org yet? Flags are covered in the feature-flag-sdk guide. The rule here is simple: the server combines all of them, plus the resource's state, into one answer per capability, and keeps the reason.

When several axes say no at once, the server reports one reason in a fixed order. A flag that is off comes first: the feature is simply absent. The plan comes next, because no role in the org can unlock it, and a plan badge keeps a paid feature discoverable; for people who cannot buy, the upgrade panel names the admin who can. Then the role, and last the resource state and quotas.

AxisChanges whenWho changes itWhat the UI says on no
Permission (role, team, ownership)An admin edits roles or membershipOrg adminsAsk an admin, or request access
Entitlement (plan)The customer upgrades, downgrades, or a trial endsBillingUpgrade, with the plan name
Feature flagA rollout moves or a kill switch firesProduct and engineeringNothing: the feature does not exist yet
Resource stateA project is archived, an issue is lockedUsers, through normal actionsExplain the state: "This project is archived"
Four axes feed one decision. On the left: permission, from role, team, and ownership; entitlement, the plan, here free; feature flag, the rollout, here on; and resource state, here an active project. All four arrows go into the authz merge in the middle, which keeps one reason in a fixed order: flag, plan, role, then state. It outputs a decision for auditLog.read: no, reason plan. A note maps reasons to messages: role means ask an admin, plan means upgrade, flag means hide, and state means explain.permissionrole, team, ownerentitlementplan: freefeature flagrollout: onresource stateproject: activeauthz mergekeeps one reasonauditLog.readno, reason: planrole: ask an adminplan: upgradeflag: hidestate: explain
The merge happens on the server, but the reason survives it. That reason, not the capability name, is what picks the message in the UI.

Check your understanding · Exercise 3

Select all that apply: an admin on the Free plan clicks Audit log. Which statements are true? Select all that apply.

What the client receives: answers, not rules

The server knows the rules. The browser must draw buttons. Something has to cross the network so the browser can decide, synchronously during render, whether to show the Delete button. What exactly should that be?

The naive answer is the role: the session payload says role: 'admin' and components compare strings. It breaks in three ways.

Custom roles mean the client does not know what Support lead may do. Resource rules mean an admin of the org may still not be able to edit a project they were removed from. And when the product team changes what members may do, every component that hard-coded the mapping must ship again.

Decision What should the API send so the UI can decide?

A capability list per org context

One request after boot or org switch: GET /api/orgs/:org/me/capabilities.

Returns the org-wide actions this user may take, with a reason for each denied one the UI might show.

Pros

  • Small: about 80 names is roughly 2 KB, well under 1 KB gzipped (estimate).
  • Synchronous checks after one request; works for nav, routes, and org-level buttons.

Cons

  • Cannot answer resource-level questions such as "may I edit this issue?".
  • Must be refreshed when roles change.

Per-resource permissions on each resource

Each resource in a response carries its own answers, like GitHub's repository object with permissions: { admin, maintain, push, triage, pull }.

Our issue would carry permissions: { update: true, delete: false, comment: true }.

Pros

  • Exact: the server ran the full rules, including ownership and state.
  • Arrives with the data, so no extra request and no waiting.

Cons

  • Adds about 60 bytes per resource (3 KB for a 50-row page, before compression).
  • Goes stale with the resource; caches must treat it as part of the entity.

Ship the policy and evaluate in the browser

Send rules in a format a client library understands, such as CASL's packed rules, or a policy bundle compiled to WebAssembly as Cerbos offers.

The browser evaluates can('update', issue) against the rules and the resource's attributes.

Pros

  • Answers any question locally, even for resources created offline.
  • One rule set shared by server and client if both use the same engine.

Cons

  • Exposes the policy: conditions, internal role names, maybe customer-specific rules.
  • Needs every attribute the rules read (team lists, owners) in the browser, and two evaluators can drift.

What we pick: Combine the first two. Org-level capabilities come once per org context and drive navigation, routes, and org buttons. Resource-level answers ride along on each resource, computed by the server. Shipping the policy is worth it only for offline-first apps, and then only a filtered rule set for this user.

Three payload choices side by side. Capability set: the API sends capability names once per org, about 2 KB, and the browser checks them with Set lookups; good for org-level checks. Per resource: each resource in a response carries about 60 bytes of answers with the data; the browser reads them; exact per item. Shipped policy: the API packs the rules and the attributes they need; the browser runs the rules; this leaks the policy.capability setAPIper org, oncebrowserSet lookupsnames onlyabout 2 KBorg-level checksper resourceAPIinside responsesbrowserreads answers60 B eachwith the dataexact per itemshipped policyAPIpacks rulesbrowserruns rulesrules andattributesleaks the policy
Our design uses the first two together. The third only pays off when the app must decide offline, and then only with rules filtered to this user.
json
{
  "org": "globex",
  "version": "c7f1-42",
  "role": "member",
  "allow": ["issue.create", "issue.comment", "project.create",
            "member.list", "label.manage"],
  "deny": {
    "auditLog.read":  { "reason": "plan", "plan": "business" },
    "role.customize": { "reason": "plan", "plan": "business" },
    "member.invite":  { "reason": "role", "askRole": "admin" }
  },
  "limits": { "privateProjects": { "used": 3, "max": 3 } }
}
GET /api/orgs/globex/me/capabilities: answers for Maya, a member of Globex on the Free plan, with reasons for denials the UI may explain.

Four details. Only answers cross the wire: no rule text, no list of other users' roles. Denials the UI might show carry a reason, so a component can pick "Upgrade" or "Ask an admin" without knowing anything about plans. Denials the UI would hide anyway (Settings for a member, internal staff tools, unreleased features) are simply absent, which also keeps their names private.

A version lets the client and server agree on which snapshot is current (chapter 8). Limits cover quotas, which behave like entitlements with a counter.

The role field is there for display only, such as "You are a Member" in the profile menu. A lint rule bans reading it in conditions. That one rule prevents the most common decay in permission code.

GraphQL APIs follow the same idea with fields on the type. GitHub's GraphQL API, for example, exposes viewerCanUpdate on issues and pull requests: the server answers for the current viewer, per object, in the same query that fetches the object. If your app uses GraphQL, add viewerCan fields to each type rather than a separate permissions query, and the client cache stores the answers next to the data they describe.

Check your understanding · Exercise 4

Select all that apply: why not ship the full policy to the browser and evaluate it there? Select all that apply.

Checking in components: one function, typed names

Now the client has answers. How do components ask? The naive code is the one every codebase grows by accident: user.role === 'admin' || user.role === 'owner' in the header, ['admin', 'owner'].includes(role) in settings, and a different list in the members page that someone forgot to update when maintainer was added.

Roles change; capabilities do not. A check should name the action, never the role.

Decision What should a component call to decide whether to show an action?

Inline role checks

Read the role from a global store and compare strings where needed.

Pros

  • No setup.

Cons

  • Breaks with custom roles and with resource rules.
  • Scattered: changing what a role may do means a hunt across files.

A can function plus a useCan hook

can(cap, resource?) returns a decision: allowed, or denied with a reason.

useCan reads the current org's capability set from context and re-renders when it changes.

Pros

  • One place to change behavior, log, and test.
  • Works in event handlers, loaders, and render.

Cons

  • Components still choose how to render a denial, so patterns can drift.

A Can component with render props

Wrap the action: Can with I="issue.delete" and a fallback.

CASL's React package offers a component in this style.

Pros

  • Reads nicely in JSX for simple show or hide.

Cons

  • Awkward when the denial needs a reason or a disabled state.
  • Nesting gets deep when several checks combine.

What we pick: Build can and useCan as the core, and put the rendering decisions (hide, disable, explain) in a small GatedAction component built on top. A thin Can component is fine as sugar for pure show or hide.

Typed names generated from the server

A typo like isue.create silently returns false, so a button vanishes for everyone and nobody notices until a customer does. Make that impossible: the server owns the list of capabilities (in its policy schema or an enum), and a build step generates a TypeScript union from it. The same file feeds the API docs and the contract test in chapter 10.

ts
// generated/capabilities.ts (do not edit)
export type OrgCapability =
  | "issue.create" | "issue.comment" | "project.create" | "label.manage"
  | "member.list" | "member.invite" | "settings.manage"
  | "auditLog.read" | "role.customize" | "billing.manage";

export type IssueAction = "update" | "delete" | "comment" | "assign";

export type DenyReason =
  | { reason: "role"; askRole: string }
  | { reason: "plan"; plan: string }
  | { reason: "state"; state: "archived" | "locked" }
  | { reason: "limit"; limit: string; max: number };

export type Decision = { allowed: true } | ({ allowed: false } & DenyReason);
Generated from the server's schema on every build. A misspelled name is a type error.
tsx
const CapsContext = createContext<CapabilitySet | null>(null);

export function useCan(cap: OrgCapability): Decision {
  const caps = useContext(CapsContext);
  if (!caps) throw new Error("useCan outside CapabilitiesProvider");
  if (caps.allow.has(cap)) return { allowed: true };
  return { allowed: false, ...(caps.deny[cap] ?? { reason: "role", askRole: "admin" }) };
}

export function canOn<A extends string>(
  resource: { permissions: Partial<Record<A, boolean>> },
  action: A,
): boolean {
  return resource.permissions[action] === true;
}
The hook: a Set lookup during render, under 1 microsecond, no network.

Two rules hide in that code. Deny by default: an unknown or missing capability means no, matching OWASP's advice for the server. And the provider throws if missing, so a component rendered outside the org context fails loudly in development instead of quietly treating everyone as allowed or denied.

Resource checks read the permissions object that came with the resource: canOn(issue, 'delete'). No list lookup, no rule evaluation. When the issue is refetched, its answers are refreshed with it.

Checks are not only for render. A keyboard shortcut handler (press # to delete) must call the same function before acting, or a guest triggers a request the button would never have sent. A command palette must filter its commands by the same checks, or it becomes a list of every hidden action. Route loaders call can outside React, which is why the core is a plain function and the hook is only a thin reader of the current org's set.

Think first

A teammate argues: "we only have four roles, role === 'admin' is simpler, we can refactor later". What do you say?

Check your understanding · Exercise 5

Select all that apply: which practices keep client checks correct as the product grows? Select all that apply.

Saying no: hide, disable, or explain

A check returns no. Now what does the screen do? This is the most visible decision in the whole design, and there is no single right answer.

Hiding everything makes paid features impossible to discover and makes support ask "why can't I see the button?" all day. Disabling everything fills the screen with grey controls nobody can use. The answer is a rule per case, decided once and applied by one component.

Decision How should the UI show an action the user cannot take?

Hide it

Do not render the control at all.

Pros

  • Clean screens for viewers and guests.
  • Leaks nothing: the user does not learn the action or the resource exists.

Cons

  • Not discoverable. Users do not know the feature exists or whom to ask.
  • Support tickets: "my colleague has a button I don't".

Disable it with a reason

Render the control as unavailable, with a short reason the user can reach by keyboard and screen reader.

Pros

  • Discoverable and self-explaining.
  • Keeps layouts stable across roles, so docs and screenshots match.

Cons

  • Grey controls everywhere feel hostile if overused.
  • Native disabled removes the control from the tab order, so its tooltip is unreachable by keyboard.

Show it, explain on click

Render a normal-looking control. On click, open a small panel: upgrade, request access, or why not.

Pros

  • Best for paid features: the click is a sales moment.
  • One clear next step instead of a dead end.

Cons

  • Feels like a trick if overused for plain role denials.
  • Must not look like the action succeeded.

What we pick: Decide by the reason, not by the component. Hide what the user cannot know about or will never be able to do. Disable with a reason when the user could do it in another state or with another role. Show and explain on click when a plan upgrade unlocks it. A GatedAction component applies this table so every screen behaves the same.

SituationTreatmentExample
The resource itself is invisible to the userHide, and 404 on direct accessA private project you are not in: not in lists, not in search, not in nav
Action for a role the user will rarely haveHideMembers and guests do not see Delete project or Manage members
Action blocked by resource stateDisable with the state as reasonEdit is disabled on an archived project: "This project is archived"
Action blocked by role, user would plausibly askDisable with who to ask, or a Request access buttonA member sees Invite people, disabled, "Only admins can invite. Ask Priya."
Action blocked by planShow, explain on click with upgradeAudit log tab with a Business badge, opening an upgrade panel
Quota reachedShow, explain the limitNew private project: "3 of 3 used on Free"
Role
Org
Force from console
The issue screen for a member in acme, Business plan, 2 of 50 private projects. Edit issue: enabled; Delete issue: not rendered; Invite people: only admins invite; Settings: not rendered; Audit log: not rendered; Private project: enabled. Forcing Delete issue from the console: DELETE /issues/2041 answer: 403 forbidden, reason: role needs delete: role member lacks it.member in acme: Business plan, 2 of 50 private projectsEdit issueenabledDelete issuehidden: not renderedInvite peopleonly admins inviteSettingshidden: not renderedAudit loghidden: not renderedPrivate projectenabledDELETE /issues/2041answer: 403 forbidden, reason: roleneeds delete: role member lacks it
The UI hid, disabled, or redirected this action, and forcing it changed nothing: the API checked again and said no, with a reason the client can show.

Edit issue: enabled; Delete issue: not rendered; Invite people: only admins invite; Settings: not rendered; Audit log: not rendered; Private project: enabled. Forced: DELETE /issues/2041 answer: 403 forbidden, reason: role needs delete: role member lacks it

Disabled controls that everyone can understand

A native button with the disabled attribute is skipped by the Tab key, ignores clicks, and is announced as unavailable when a screen reader user finds it by browsing. That is fine for a Save button waiting for valid input. It is bad for a permission denial, because the reason usually lives in a hover tooltip, and a keyboard user can never focus the button to see it.

That breaks WCAG 2.1.1, which asks that everything work from a keyboard. Mouse users are not safe either: disabled controls get no click events, and browsers have not always fired hover events on them, so a tooltip attached to the button itself can fail. Tooltip libraries such as MUI and Bootstrap document this and suggest wrapping a disabled button in another element.

For permission denials use aria-disabled="true" instead. The button stays focusable, assistive technology announces it as unavailable, and you block activation in the click handler. Link the reason with aria-describedby so it is read after the button's name. Show the same text visibly on focus and hover, or inline next to the button.

If the reason lives in a tooltip, WCAG 1.4.13 (content on hover or focus) adds three rules: the user can dismiss it without moving focus, usually with Escape; the pointer can move onto it without it vanishing; and it stays until the user moves away. Inline text satisfies all of this for free, which is why GatedAction below renders the reason next to the button. The aria-patterns lesson covers tooltips and descriptions in depth.

tsx
function GatedAction({ decision, label, onRun, hideOn = ["role"] }: Props) {
  const reasonId = useId();
  if (!decision.allowed && hideOn.includes(decision.reason)) return null;
  if (!decision.allowed && decision.reason === "plan")
    return <UpgradeButton label={label} plan={decision.plan} />;
  const blocked = !decision.allowed;
  return (
    <>
      <button
        type="button"
        aria-disabled={blocked || undefined}
        aria-describedby={blocked ? reasonId : undefined}
        onClick={blocked ? undefined : onRun}
      >{label}</button>
      {blocked && <span id={reasonId} className="hint">{reasonText(decision)}</span>}
    </>
  );
}
One component turns a decision into the right treatment. Reasons are reachable by keyboard and screen readers.

WCAG 1.4.3 exempts inactive controls from contrast requirements, which tempts designers to make them pale grey. But the reason text is not inactive: it must meet normal contrast (4.5 to 1 for body text). And do not rely on color alone; the reason in words is what tells the user what to do next.

The best denial offers a next step. A Request access button sends POST /access-requests with the capability and the resource, the admin gets a notification with Approve and Decline, and the requester's open tab picks up the new capability through the same refresh path as a revocation (chapter 8). Rate limit it, for example one request per resource per user per day, so a denial does not turn into spam for admins.

Hiding has to be complete to protect anything. A private project hidden from the sidebar can still leak through a search result count ("14 projects" when 13 are listed), an autocomplete when mentioning #web-, an activity feed entry, or a page title in browser history. Every one of those is a server query, and every one must be filtered by the same rules on the server.

Check your understanding · Exercise 6

Select all that apply: a member cannot invite people because only admins may. Which treatments are good? Select all that apply.

Routes, navigation, and server rendering

Buttons are one surface; pages are another. A member pastes /acme/settings/billing from a Slack message. A former contractor clicks an old bookmark to a private project. A careless sidebar lists the names of private projects the user should not even know about, and each case needs a deliberate answer.

Navigation is data: a list of items, each with an optional requires capability. The sidebar renders only items whose check passes, except plan-gated items, which render with a badge so the feature stays discoverable. Project lists come from the API already filtered to projects the user can see, so private project names never reach the browser.

ts
export const settingsRoutes = [
  { path: "general",  label: "General",   requires: "settings.manage" },
  { path: "members",  label: "Members",   requires: "member.list" },
  { path: "roles",    label: "Roles",     requires: "role.customize" },
  { path: "audit",    label: "Audit log", requires: "auditLog.read" },
  { path: "billing",  label: "Billing",   requires: "billing.manage" },
] as const satisfies readonly GuardedRoute[];
Nav and routes share one table, so a page cannot be in the menu but blocked by the guard, or the reverse.

Route guards that run before render

A guard written as useEffect(() => { if (!can) navigate('/403') }) renders the forbidden page first, fires its data requests, and then redirects. That is the flash we promised not to have, and it sends requests the server will reject. Run the check before render instead: in a route loader (React Router), in server code before streaming HTML (Next.js), or in a wrapper that renders nothing until capabilities are ready. The routing and ssr lessons cover where loaders run.

Decision What should a user see when they open a page they may not access?

Redirect to home

Silently send them to the dashboard.

Pros

  • No error page to design.

Cons

  • Confusing: the link "does nothing".
  • Loses the URL, so the user cannot even forward it to an admin.

A 403 page in place

Keep the URL. Render "You don't have access to Billing" with who to ask or a Request access button.

Pros

  • Honest, and actionable.
  • The URL stays, so the request access flow knows what was asked for.

Cons

  • Confirms that the page or resource exists.

A 404 page

Render the same Not found page a mistyped URL gets.

Pros

  • Reveals nothing, like GitHub returning 404 for private repositories you cannot access.

Cons

  • Unhelpful for people who should ask for access.

What we pick: Use 403 in place for pages whose existence is not a secret, such as Billing or Settings. Use 404 for resources whose existence is the secret, such as a private project or an issue in it. The server makes this choice, and the client renders whatever status the server sent.

The status codes come from RFC 9110, the HTTP semantics standard. 401 Unauthorized means the request has no valid credentials, so the right reaction is to sign in again (the auth-flow guide). 403 Forbidden means the server knows who you are and refuses. The same section lets a server answer 404 Not Found instead of 403 when it wants to hide that the resource exists, and GitHub's REST API does exactly that for private repositories.

So the API client branches on all three: 401 starts the sign-in path, 403 refetches capabilities and shows the reason, and 404 renders not found. Mixing up 401 and 403 is a common bug: a demoted user gets bounced to the login page, signs in again, and lands on the same denial.

A sequence diagram of two deep links checked on the server before any HTML is sent. Columns: user, browser, SSR server, authz. First, a former team member opens a link to private project 812. The browser requests the page; the SSR server asks authz whether the user can view project 812; authz says no, not a member; the server returns a 404 page with Cache-Control no-store, and no project shell or name is sent. Second, a member opens the billing page. The server asks authz for billing.manage; authz says no, reason role; the server returns a 403 page that names who to ask.userbrowserSSR serverauthzopen old linkGET project 812view 812?no: not member404, no-storeno shell sentGET billingbilling.manage?no: role403: ask Priya
The status depends on what the answer would leak. A private project's existence is a secret, so it gets the same 404 as a typo. Billing exists for every org, so a 403 with a next step is more helpful. Neither page is ever rendered and then hidden.

Server rendering: check before sending HTML

Server-rendered pages turn the golden rule into a sharper one: the server must decide before it sends a single byte of the protected page. HTML that was rendered and then hidden by client code is already in the response, readable with View Source. So the server-side loader loads the session, the org, the resource, runs the same authz.check, and either renders the page with the capabilities embedded or returns a 403 or 404 status with the matching page.

Getting the status code right matters beyond users. Monitoring counts real 404s and 403s, and caches and CDNs must never store a personalized page. Pages that depend on permissions should send Cache-Control: private, no-store or at least private, so a shared cache cannot serve an admin's settings page to a member.

Code splitting interacts with this too. Splitting admin screens into their own chunk means members do not download 150 KB (an estimate for a settings area) of code they cannot use, so load the chunk only after the guard passes. But do not treat that as protection: chunk URLs are public, and anyone can fetch and read the admin code. Code is not a secret; data and actions are, and the server guards those.

Check your understanding · Exercise 7

Select all that apply: a former team member opens a bookmark to /acme/projects/812, a private project they were removed from. What should happen? Select all that apply.

Keeping it fresh: loading, revocation, and switching orgs

Capabilities are server state: the server owns them, the client holds a copy, and the copy can be missing, loading, or stale. Every bug in this chapter comes from forgetting that. The server-state-libraries lesson covers the general tools; here are the three moments that matter for permissions.

First paint: no flash in either direction

If components render before capabilities arrive, they must guess. Guess allowed and the admin toolbar flashes for a member, then vanishes. Guess denied and an admin sees the toolbar pop in after 300 ms, shifting the layout (a Cumulative Layout Shift hit). Neither is acceptable. Two fixes, best first:

  1. Bootstrap in the HTML. The server already computed capabilities to decide whether to render the page. It embeds them in a script tag of type application/json, and the provider reads them synchronously before hydration. Zero extra requests, nothing to wait for.
  2. Block the gated region, not the page. For client-only routes, render the page frame and skeletons for areas that depend on capabilities until the request returns. A skeleton the size of the toolbar avoids both the flash and the layout shift.

Revocation while the page is open

At 10:02 Priya, an admin, demotes Sam from editor to viewer on the Web project. Sam's tab loaded at 9:00 and still shows Edit everywhere. The API is already correct: Sam's next write gets 403. The question is only how long Sam's UI lies, and what Sam sees when it is caught.

Decision How does an open tab learn that its capabilities changed?

Only at boot

Fetch once, keep until reload.

Pros

  • Simplest.

Cons

  • The UI is wrong until the user reloads, maybe for days in a pinned tab.
  • Every forced action fails with a generic error.

Refetch on 403 and on focus

Any 403 with code: forbidden refetches capabilities and the affected resource.

Also refetch when the tab regains focus if the copy is older than 5 minutes.

Pros

  • No new infrastructure.
  • Fixes the UI exactly when it matters: the moment it lied.

Cons

  • The user still loses one action and sees one error.
  • Nothing changes for a user who only reads.

Push a change event

The server sends capabilities.changed with the new version over the app's existing WebSocket or SSE channel.

The client refetches if the version differs.

Pros

  • UI catches up within a second or two, before the user tries anything.
  • Also fixes read-only screens that should disappear.

Cons

  • Needs a live channel and fan-out per affected user, which matters when a role edit touches 15,000 members.

What we pick: Always implement refetch on 403: it is the safety net and costs almost nothing. Add pushed events if the app already has a live channel, as most collaborative B2B apps do. Send only a version, never the capabilities themselves, so the push stays tiny and the normal authenticated request stays the single source.

Strategy
An admin revokes Sam's editor role at 10 seconds. Strategy: refetch on 403. Sam clicks Save at 40 seconds. The UI is wrong for 31 s, with 1 failed action. At 45 seconds, Sam's tab shows Read only: Your role changed to Viewer. Edit not saved. Copy text.seconds after load: green right, red wrong, hatched partly wrong01020304050607080nowSam's tab at 45 sRead onlyUI matches APIYour role changed to Viewer.Edit not saved. Copy text.wrong for: 31 sfailed actions: 1revoked at 10 sthe 403 body triggers a refetch of capabilities
The API is right from the first second; only the UI lags. Refetch on 403 fixes it at the moment of the failed action, and within this window never for someone who only reads. A pushed version closes the window before Sam can try anything.

UI wrong for 31 s. At 45 seconds: Read only. Your role changed to Viewer. Edit not saved. Copy text.

The graceful 403 matters as much as the refresh: Sam was editing a description. The form must not throw away the text: show "Your role changed to Viewer. Your edit was not saved" with a Copy text button, then re-render the page read-only from the fresh capabilities. Move focus to the message so screen reader users hear it (see the focus-management lesson).

Versions keep this cheap. The capability response carries version (for example a hash of the role assignments) and an ETag. The refetch sends If-None-Match, and an unchanged set returns 304 Not Modified with no body. API responses can also carry an X-Capabilities-Version header; if it differs from the client's, the client refetches in the background.

Tabs share the update. When one tab fetches a new version, it posts it on a BroadcastChannel named per org, so the other four tabs swap without each one failing a request first. The cross-tab-sync lesson covers the channel and its fallbacks.

Watch the fan-out. An admin who edits the Member role in a 15,000-person org changes the capabilities of everyone holding it. If the server pushes capabilities.changed to all of them and every open tab refetches at once, that is thousands of requests in the same second.

Two fixes: the push carries the new version, so tabs that already have it do nothing, and each client waits a random 0 to 5 seconds before refetching, which spreads 15,000 requests to about 3,000 per second. Revocations of a single user still apply at once, because the server enforced them from the first moment.

A state machine of the client's capability set for one org. It starts in no set, on boot or org switch. Fetch leads to loading, which shows skeletons only. An error leads to failed, which denies everything and retries. A 200 reply leads to ready, where checks are synchronous. A bootstrap from the HTML goes from no set straight to ready. From ready, a 403, a push, or a version header leads to stale. Stale refetches into revalidating, which keeps showing the current set. A 304 or a new set returns to ready. A note: switching org starts again at no set for the new org's key.no setboot or org switchloadingskeletons onlyfaileddeny all, retryreadychecks are sync200 OKfrom HTMLstale403, push, headerrevalidatingkeeps current setsignalrefetch, If-None-Match304 or new setorg switch: back to no set, new key
Only loading blocks the gated parts of the screen. stale and revalidating keep showing the current set, because a short wait for a correction is better than a flash of skeletons on every push.

Switching organizations

Sam is an admin in Acme and a guest in Globex. Switching orgs swaps the whole capability set, and the classic bug is one frame of Globex data rendered with Acme's admin buttons. Fix it structurally: key the capability cache by org (['caps', orgId]), put the org in the URL (/globex/...), and make the provider render skeletons, not the previous org's set, while the new set loads. Cached sets for recently visited orgs make the second switch instant, still revalidated in the background.

A sequence diagram of switching from Acme, where Sam is an admin, to Globex, where he is a guest. Columns: user, router, caps cache, API, page. Sam picks Globex in the switcher. The router changes the URL to the globex prefix and asks the cache for globex capabilities. The cache misses, so the page renders skeletons, not Acme's admin buttons. The cache fetches the Globex capability set from the API, which returns version 9 with role guest. The page renders the guest UI. A label at the bottom: switching back to Acme is a cache hit, shown at once and revalidated in the background.userroutercaps cacheAPIpagepick globexcaps globex?skeletonsGET capsv9, guestguest UIback to acme: cache hit, shown at once, revalidated
The bug this prevents is one frame of Globex data with Acme's admin buttons. Keying the cache by org and putting the org in the URL make that frame impossible, rather than unlikely.

Check your understanding · Exercise 8

Select all that apply: Sam's role was downgraded while his tab was open. Which behaviors are correct? Select all that apply.

Fields, forms, and the admin screen that edits roles

Field-level permissions

Some rules apply to parts of a resource. A member can edit an issue's title and description but not its priority on a project where only leads triage. A contractor sees an employee's name but not their salary band. The same three treatments apply, with one twist: for fields, hiding and read-only mean different things for the data.

  • Read-only fields are sent to the client and shown, but not editable. Use them when seeing the value is fine: the priority is visible to everyone, only leads change it. Render as text, not as a disabled input, so it reads well and is not mistaken for broken.
  • Hidden fields must not be sent at all. If the salary band is in the JSON, hiding the input is decoration. The server removes the field from the response for users who may not read it.
json
{
  "id": "ISS-2041", "title": "Export fails on large CSVs",
  "priority": "high", "status": "in_progress",
  "permissions": { "update": true, "delete": false, "comment": true },
  "fields": { "title": "write", "description": "write",
              "priority": "read", "status": "write" }
}
The issue carries action answers and field answers. Missing fields were never sent.

On writes, the server applies the same field rules. If Sam sends { "priority": "urgent" } from the console, the server must reject it with a 403 naming the field, or ignore it. Rejecting is better for debugging; silently ignoring is common too.

What it must never do is copy every incoming property onto the record: that is mass assignment, and OWASP's API Security Top 10 lists it under broken object property level authorization. The form-builder guide covers rendering forms from a schema; the field map above plugs into that schema as a readOnly flag per field.

Optimistic updates need one extra rule. Applying an edit before the server confirms it is fine when the field says write; if the server then answers 403, roll back, keep the user's text in the form, and refetch the issue so its fields map is current. Never apply optimistic edits to fields the map says are read: the UI would show a change that never happened, then snap back.

The roles admin screen

Admins need to see and edit what each role may do. The natural layout is a permissions matrix: capabilities as rows, grouped by area (Issues, Projects, Members, Billing), roles as columns, a checkbox in each cell. Built-in roles show locked columns; custom roles (a Business entitlement) are editable. With 60 capabilities and 6 roles it is 360 cells, so it needs grouping, a search box, and plain-language descriptions per row ("Delete projects, including all their issues").

The admin-cms guide covers concurrent edits to settings like this; here, save the whole role as one versioned write so two admins cannot half-merge.

A wireframe of the Roles settings page. A matrix with capabilities as rows and roles as columns: Admin, Member, Guest, and a custom role, Contractor. Rows: issue.create, yes for admin, member, and contractor, no for guest; issue.delete, project.delete, member.invite, and billing.manage, yes only for admin. The three built-in columns are locked; the Contractor column is editable. Below, two buttons: View as Contractor, and Save role as version 7. A note: built-in roles are locked, and a custom role is saved as one versioned write.acme / settings / rolesAdminMemberGuestContractorissue.createyesyesnoyesissue.deleteyesnononoproject.deleteyesnononomember.inviteyesnononobilling.manageyesnononoView as ContractorSave role v7built-in roles locked; custom role saved as one write
Rows are capabilities, the same names the client checks. View as asks the server for that role's set, so the preview shows the real answers, not a guess.

Admins also want to check their work: "what does a Contractor actually see?". A view as role preview answers it. Do it safely: the server computes the capability set for the chosen role in this org and returns it with preview: true; the client swaps it into the provider and shows a fixed banner "Previewing as Contractor. Exit preview".

Every write is blocked while previewing, by the client and, more importantly, because the admin's real session still sends their real identity and the server knows it is a preview. Never implement this by issuing a token for another user: impersonation of a real person is a separate, audited support feature, not a settings preview.

Check your understanding · Exercise 9

Select all that apply: contractors may not read salary bands. Which designs are safe? Select all that apply.

Architecture, API, testing, and performance

Put the pieces together. The goal of the architecture is that a component never needs to know how a decision was made: it asks a question by name and renders the answer. Everything about fetching, caching, refreshing, and reasons lives in one small layer.

The component architecture. Server zone: the authz service, which checks and builds capability sets, feeds the API and SSR loaders, which check every call; the API tells the socket hub on change. Browser zone, first row: the API client, which turns a 403 into a refetch; the caps store, per org with a version; and the live client, for the socket and other tabs. The API sends JSON and 403s to the API client; the socket hub sends versions to the live client. The API client sets the caps store; the live client marks it stale. Second row: the CapsProvider with useCan and canOn subscribes to the store and feeds GatedAction, which hides, disables, or offers an upgrade, and routes and nav, which guard before render. A note: components ask by name and never see roles or rules.serverauthz servicechecks, cap setsAPI and SSRcheck every callsocket hubcaps.changedbrowserAPI client403 to refetchcaps storeper org, versionlive clientsocket and tabsJSON, 403versionGatedActionhide, disable, upgradeCapsProvideruseCan, canOnroutes and navguard before rendersubscribecomponents ask by name, never see rules
Every arrow into the browser carries answers or versions, never rules. Components only touch the bottom row, so how the server decides can change without a single component changing.
PartResponsibilityWhy the boundary is here
Authz service (server)Evaluates roles, relationships, attributes, and plan. Answers checks for handlers and builds capability setsOne source of truth for the API and the UI
API and SSR loadersCall authz.check on every request; embed capabilities in HTML; add permissions to resourcesEnforcement must happen where the client cannot reach
Capability storeHolds one set per org, keyed by org, with version; refetches on 403, push, focusServer state with its own freshness rules, separate from UI state
CapabilitiesProvider, useCan, canOnSynchronous checks for components; deny by defaultComponents ask by name, never see roles or rules
GatedAction, guarded routes, nav tableTurn a decision into hide, disable with reason, or upgrade; filter nav; guard routes before renderConsistent treatment across every screen
API clientDetects 403 with code: forbidden, triggers refetch, surfaces the reasonOne place notices staleness for the whole app
Live channel and BroadcastChannelDeliver capabilities.changed versions from the server and between tabsFreshness without polling
The data flow for the issue page. Read path, top to bottom on the left: authz and the database send decisions to the SSR loader and API, which send HTML with the capability JSON and the issue with its permissions to the query cache, keyed by caps and org and by issue; the cache feeds the issue page with synchronous reads. Write path on the right: the page sends a PATCH to the API client, which sends it with a CSRF token to the API write handler, where authz checks again. The handler answers 204 or 403. On 403, the API client invalidates the capabilities in the query cache, which refetches.authz, DBdecidesSSR loader, APIread pathquery cachecaps, issue by orgissue pagesync readsdecisionscaps JSON, permissionsAPI writeauthz.check againAPI clientsees the statusPATCH issuePATCH, CSRF403invalidate
The write path repeats the check the read path already did. That is the point: the read path decides what to show, the write path decides what happens, and a 403 on the right is how the left side learns it was stale.

Client data model

ts
type CapabilitySet = {
  orgId: string;
  version: string;               // compared with pushes and headers
  allow: ReadonlySet<OrgCapability>;
  deny: Partial<Record<OrgCapability, DenyReason>>;
  limits: Record<string, { used: number; max: number }>;
  fetchedAt: number;
  preview?: { role: string };    // set only in "view as" mode
};

type Issue = {                    // in the server-state cache, per resource
  id: string; title: string; priority?: Priority;
  permissions: Partial<Record<IssueAction, boolean>>;
  fields: Partial<Record<keyof IssueFields, "read" | "write">>;
};
// Cache keys: ["caps", orgId] and ["issue", orgId, id].
// The org also lives in the URL: /acme/projects/web/issues/2041
Where each piece of permission data lives on the client.

Capability sets live in the server-state cache (TanStack Query or similar), not in a global client store, because their lifecycle is fetch, revalidate, invalidate. Resource permissions live inside each resource, so they are invalidated together with it. Nothing about permissions goes into localStorage: a stale copy from last week is worse than none, and a shared computer should not keep it. The global-vs-local-state lesson explains the split.

Make the layer observable. The API client reports every 403 with the capability, the client's capability version, and whether the UI offered the action. A 403 on an action the UI offered means the client was stale or the client and server disagree; a steady rate above a fraction of a percent of writes points to a bug, not to revocations. A spike of 403s on actions the UI never offered, from one account, is someone probing the API, which the security team wants to know about.

API surface

Endpoint or messagePurposeNotes
GET /api/orgs/:org/me/capabilitiesOrg-level set for the current userETag, Cache-Control: private, no-cache; 304 when unchanged
permissions and fields on every resourceResource-level answersComputed per request; part of the entity
POST /api/orgs/:org/permissions/checkBatch check for resources the client holds without answersBody: up to 100 { action, resource } pairs; returns booleans in order
403 { code: "forbidden", required, reason }Denied actionClient refetches capabilities and shows the reason
404Resource the user may not know aboutSame body as a missing ID
X-Capabilities-Version headerCheap staleness hint on any responseClient refetches in the background if it differs
socket { type: "capabilities.changed", org, version }Push after a role or plan changeNever carries the capabilities themselves
POST /api/orgs/:org/roles/:id/previewCapability set for view as roleAdmins only; response marked preview: true

Check your understanding · Exercise 10

Select all that apply: where should the org's capability set be stored on the client? Select all that apply.

Testing: from units to revocation

Permission bugs are quiet. Nobody files a ticket when a member sees a button that does nothing; they just stop trusting the app. Tests have to catch them before users do, in four ways (the testing-strategy lesson covers the general pyramid).

  • Unit tests for the layer: useCan denies unknown names, the provider throws outside an org, GatedAction picks hide, disable, or upgrade for each reason, and the disabled button has aria-disabled and a description. Fast and exhaustive.
  • A role matrix of end-to-end tests: for each of a few key screens, sign in as owner, admin, member, guest, and a Free plan admin, and assert which actions are visible, disabled, or hidden. Then force one forbidden action per role through the API and assert 403. Five roles by six screens is 30 short tests; run them in parallel.
  • Contract tests on names: a CI job compares the server's capability list with the generated client union. A capability the client checks but the server never grants means a button that never appears. The contract-and-visual-tests lesson covers the tooling.
  • A revocation test: sign in as an editor, open an issue, demote the user through the admin API in the middle of the test, then click Save. Assert the 403 message, that the typed text is still there, and that Edit is gone after the refetch. With pushes enabled, assert that Edit disappears before any click.
ts
const matrix = [
  { as: "admin",    plan: "business", invite: "enabled", audit: "enabled" },
  { as: "member",   plan: "business", invite: "disabled", audit: "hidden" },
  { as: "guest",    plan: "business", invite: "hidden",  audit: "hidden" },
  { as: "admin",    plan: "free",     invite: "enabled", audit: "upgrade" },
] as const;

for (const row of matrix) {
  test(`${row.as} on ${row.plan}`, async ({ page }) => {
    await signInAs(page, row.as, row.plan);
    await page.goto("/acme/settings/members");
    await expectAction(page, "Invite people", row.invite);
    await expectNav(page, "Audit log", row.audit);
  });
}
A role matrix written as data, so adding a role is one row.

Performance: one request, synchronous checks

The classic performance bug is a component that fetches its own permission: a list of 50 issue rows, each calling GET /issues/:id/can-delete, is 50 requests and 50 spinners. The design above avoids it on purpose. Org-level checks are a Set lookup against one set fetched once per org (or zero times, if bootstrapped). Resource-level answers come inside the list response itself, so 50 rows cost no extra requests and about 3 KB of extra JSON before compression.

When the client holds resources without answers (search results from another service, mentions in a comment), batch: collect the checks for one render, send them in one POST /permissions/check with up to 100 pairs, and cache the results with the resources. A per-request batcher like a DataLoader coalesces calls made in the same tick. The server answers a batch with one database round trip per resource type, typically a few milliseconds (estimate).

Also keep re-renders cheap: the provider's value should only change when the version changes, so a background revalidation that returns 304 re-renders nothing.

Check your understanding · Exercise 11

Select all that apply: which tests catch real permission bugs? Select all that apply.

Running it in the interview

In 45 minutes you cannot cover all of this. Spend the time where the signal is: the golden rule, what crosses the wire, how denials look, and staleness.

MinutesWhat to doWhat to say or draw
0 to 5ScopeCustom roles? Plans? Multi-org? How fast must revocation show? Server rendering?
5 to 8Golden rule"The server enforces every request; the client only reflects." Show the forced call returning 403.
8 to 16Model and payloadCapabilities as the unit; plan as a separate axis; capability set per org plus permissions on resources. Draw the payload.
16 to 24Components and denialsuseCan, typed names, GatedAction; hide vs disable vs upgrade by reason, with aria-disabled and a reachable reason.
24 to 34Routes and freshnessGuards before render, 403 vs 404, SSR check, bootstrap in HTML, refetch on 403, push version, per-org cache.
34 to 40ArchitectureDraw the layers from the authz service to GatedAction.
40 to 45Follow-upsTesting matrix, batching, view as role.

Draw two things early: the forced-call sequence ending in 403, and the capability payload. They anchor every later answer. When the interviewer pushes on a detail, name the trade-off before picking: "hiding leaks nothing but hurts discovery, so for plan features I show an upgrade path instead". Interviewers score the reasoning, not the final choice.

Follow-ups to expect

Think first

The interviewer asks: Why not put the roles in the JWT and read them on the client?

Think first

The interviewer asks: How is this different from feature flags? Can't we use the flag SDK?

Think first

The interviewer asks: Five teams build parts of this app as micro frontends. Who owns permissions?

Check your understanding · Exercise 12

Select all that apply: which statements would you want the interviewer to hear in the first 10 minutes? Select all that apply.

Summary

  • The server enforces every request; the client only reflects. If every client check vanished, the app would be worse to use but leak nothing.
  • Components check named capabilities, never role names. Roles are bundles that change; capabilities are what the code means.
  • Send answers, not rules: one capability set per org, plus permissions and fields on each resource. Ship a policy to the browser only for offline-first apps.
  • Role, plan, rollout, and resource state are different reasons for no, and each needs a different message: ask an admin, upgrade, nothing, or explain the state.
  • Hide what users cannot know about, disable with a keyboard-reachable reason what they could do in another state or role, and turn plan limits into an upgrade path. Use aria-disabled and aria-describedby for permission denials.
  • Guard routes before render, on the server for SSR pages. Use 404 for resources whose existence is secret and 403 for pages that are not.
  • Capabilities are server state: bootstrap them in the HTML, key them by org, refetch on 403, push a version on change, and share it across tabs.
  • Data the user may not read never crosses the wire, and the server rejects fields the user may not write.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 3

References

Concepts behind it

Try next