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
| Question | Answer we assume | What 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 plan | Custom 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 projects | A 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 Business | Entitlements 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 seconds | We 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 switcher | Capabilities 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 speed | The 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.
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.
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." } }
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.
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.
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();
});
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?
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.
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.
| Axis | Changes when | Who changes it | What the UI says on no |
|---|---|---|---|
| Permission (role, team, ownership) | An admin edits roles or membership | Org admins | Ask an admin, or request access |
| Entitlement (plan) | The customer upgrades, downgrades, or a trial ends | Billing | Upgrade, with the plan name |
| Feature flag | A rollout moves or a kill switch fires | Product and engineering | Nothing: the feature does not exist yet |
| Resource state | A project is archived, an issue is locked | Users, through normal actions | Explain the state: "This project is archived" |
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?
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.
{
"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 } }
}
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?
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.
// 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);
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;
}
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?
The capability version is not more code: useCan('settings.manage') is as short as the role check, and it survives custom roles, plan changes, and roles gaining or losing rights without touching components.
"Refactor later" means finding every role comparison across the codebase, and missing one is a visible bug or a confusing UI. A lint rule that bans reading role in conditions keeps it from coming back.
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?
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.
| Situation | Treatment | Example |
|---|---|---|
| The resource itself is invisible to the user | Hide, and 404 on direct access | A private project you are not in: not in lists, not in search, not in nav |
| Action for a role the user will rarely have | Hide | Members and guests do not see Delete project or Manage members |
| Action blocked by resource state | Disable with the state as reason | Edit is disabled on an archived project: "This project is archived" |
| Action blocked by role, user would plausibly ask | Disable with who to ask, or a Request access button | A member sees Invite people, disabled, "Only admins can invite. Ask Priya." |
| Action blocked by plan | Show, explain on click with upgrade | Audit log tab with a Business badge, opening an upgrade panel |
| Quota reached | Show, explain the limit | New private project: "3 of 3 used on Free" |
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.
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>}
</>
);
}
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 filtered by capability
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.
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[];
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?
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.
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:
- Bootstrap in the HTML. The server already computed capabilities to decide whether to render the page. It embeds them in a
scripttag of typeapplication/json, and the provider reads them synchronously before hydration. Zero extra requests, nothing to wait for. - 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?
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.
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.
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.
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.
{
"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" }
}
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.
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.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Authz service (server) | Evaluates roles, relationships, attributes, and plan. Answers checks for handlers and builds capability sets | One source of truth for the API and the UI |
| API and SSR loaders | Call authz.check on every request; embed capabilities in HTML; add permissions to resources | Enforcement must happen where the client cannot reach |
| Capability store | Holds one set per org, keyed by org, with version; refetches on 403, push, focus | Server state with its own freshness rules, separate from UI state |
CapabilitiesProvider, useCan, canOn | Synchronous checks for components; deny by default | Components ask by name, never see roles or rules |
GatedAction, guarded routes, nav table | Turn a decision into hide, disable with reason, or upgrade; filter nav; guard routes before render | Consistent treatment across every screen |
| API client | Detects 403 with code: forbidden, triggers refetch, surfaces the reason | One place notices staleness for the whole app |
Live channel and BroadcastChannel | Deliver capabilities.changed versions from the server and between tabs | Freshness without polling |
403 on the right is how the left side learns it was stale.Client data model
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
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 message | Purpose | Notes |
|---|---|---|
GET /api/orgs/:org/me/capabilities | Org-level set for the current user | ETag, Cache-Control: private, no-cache; 304 when unchanged |
permissions and fields on every resource | Resource-level answers | Computed per request; part of the entity |
POST /api/orgs/:org/permissions/check | Batch check for resources the client holds without answers | Body: up to 100 { action, resource } pairs; returns booleans in order |
403 { code: "forbidden", required, reason } | Denied action | Client refetches capabilities and shows the reason |
404 | Resource the user may not know about | Same body as a missing ID |
X-Capabilities-Version header | Cheap staleness hint on any response | Client refetches in the background if it differs |
socket { type: "capabilities.changed", org, version } | Push after a role or plan change | Never carries the capabilities themselves |
POST /api/orgs/:org/roles/:id/preview | Capability set for view as role | Admins 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:
useCandenies unknown names, the provider throws outside an org,GatedActionpicks hide, disable, or upgrade for each reason, and the disabled button hasaria-disabledand 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.
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);
});
}
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.
| Minutes | What to do | What to say or draw |
|---|---|---|
| 0 to 5 | Scope | Custom roles? Plans? Multi-org? How fast must revocation show? Server rendering? |
| 5 to 8 | Golden rule | "The server enforces every request; the client only reflects." Show the forced call returning 403. |
| 8 to 16 | Model and payload | Capabilities as the unit; plan as a separate axis; capability set per org plus permissions on resources. Draw the payload. |
| 16 to 24 | Components and denials | useCan, typed names, GatedAction; hide vs disable vs upgrade by reason, with aria-disabled and a reachable reason. |
| 24 to 34 | Routes and freshness | Guards before render, 403 vs 404, SSR check, bootstrap in HTML, refetch on 403, push version, per-org cache. |
| 34 to 40 | Architecture | Draw the layers from the authz service to GatedAction. |
| 40 to 45 | Follow-ups | Testing 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?
A token is a snapshot: a role revoked at 10:02 stays in a token until it expires, maybe an hour later. The server must still check current state, so the token only adds a second, staler copy.
Tokens also grow with every org a user belongs to, and they carry roles, not capabilities, which brings back role checks in components. Keep tokens about identity (the auth-tokens lesson) and fetch capabilities per org.
Think first
The interviewer asks: How is this different from feature flags? Can't we use the flag SDK?
Flags answer "is this feature released to you?" and change by rollout; permissions answer "may you do this?" and must be enforced by the server on every request. A flag being off is not a security boundary.
They meet in the UI: a capability is allowed only if the flag is on, the plan includes it, and the role grants it. The server merges them into one capability answer; the client keeps one checking API. See the feature-flag-sdk guide for the flag side.
Think first
The interviewer asks: Five teams build parts of this app as micro frontends. Who owns permissions?
The shell owns the capability store and provider and exposes useCan through the shared package, so every micro app sees the same set and the same version. Each app adds its capability names to the server schema, and the generated union covers all of them.
No app fetches its own copy; that would mean five versions that can disagree after a revocation. The micro-frontend-shell guide covers how shared services cross app boundaries.
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
permissionsandfieldson 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-disabledandaria-describedbyfor 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.
References
- OWASP Top 10 2021, A01 Broken Access Control Why access control failures lead the list, and how to prevent them on the server.
- OWASP Top 10 2025, A01 Broken Access Control Still first in the 2025 edition, with the same advice: enforce in trusted server code and deny by default.
- OWASP Authorization Cheat Sheet Deny by default, check on every request, prefer attribute and relationship models over plain roles.
- OWASP API Security Top 10 2023, API3 Broken Object Property Level Authorization Field-level reads and mass assignment on writes.
- Zanzibar: Google's Consistent, Global Authorization System (USENIX ATC 2019) The relationship-based model behind Drive and other Google products.
- OpenFGA documentation An open-source, Zanzibar-inspired authorization service, a CNCF project.
- SpiceDB documentation Another open-source Zanzibar-inspired database for permissions.
- GitHub Docs: Troubleshooting the REST API Why GitHub returns 404 instead of 403 for private resources.
- RFC 9110: HTTP Semantics, 403 Forbidden 401 vs 403, and the explicit permission to answer 404 to hide that a resource exists.
- GitHub REST API: Repositories The
permissionsobject (admin, maintain, push, triage, pull) on each repository. - CASL documentation An isomorphic JavaScript authorization library, with packed rules and a React component.
- WAI-ARIA Authoring Practices: Developing a Keyboard Interface Focusability of disabled controls and when to keep them focusable.
- MDN: aria-disabled Keeps a control focusable while announcing it as disabled; activation must be blocked in script.
- Understanding WCAG 2.2: 1.4.13 Content on Hover or Focus Dismissible, hoverable, and persistent rules for tooltips that explain a denial.
Concepts behind it
- Authentication and token storageHow a web app proves who the user is on every request after login: sessions or tokens, where the proof lives in the browser, how it is refreshed and revoked, and how OAuth with PKCE and OIDC fit in.
- REST for frontendsA way to shape an HTTP API around resources and a few standard verbs, so the frontend can fetch, cache, retry, and handle errors in the same predictable way everywhere.
- API contracts, versioning, and errorsThe written agreement between the frontend and the API: a typed schema both sides are checked against, rules for changing it without breaking old clients, and one error shape the UI can always understand, including for partial failures and safe retries.
- Client-side routingHow single-page apps change screens without reloading: matching URLs to nested layouts, loading each route's code and data in parallel, guarding private pages, and restoring scroll and focus.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Cache invalidationHow every copy of a piece of data, from the server cache to the CDN to the open tab, learns that it is out of date: by time, by event, or by a new name, in the right order and without a stale read sneaking back in.
- Syncing state across tabsEvery open tab of your app is a separate program with its own memory, so a change in one tab is invisible to the others until you send it across with the storage event, BroadcastChannel, a SharedWorker, or a leader tab chosen with Web Locks.
- A frontend testing strategyHow to split a frontend's tests between static checks, unit, integration, and end-to-end tests, so they catch the bugs that matter without making every change slow.