Keeping Frontend and Backend Permissions in Lockstep in a Multi-Tenant App
Authorization looks solved until you ship it. The hard part isn't hiding a button or rejecting a request — it's making the two agree. Here's a pattern that closes the gap structurally, so the UI can never show a door that opens onto a wall.
Authorization looks solved until you ship it. Every framework hides a button; every backend rejects a request. The hard part isn't either mechanism — it's making them agree. In a multi-tenant SaaS product where customers define their own roles and the surface grows every sprint, the gap between "what the UI shows" and "what the server allows" is where the real bugs live. This is the pattern I used to close that gap structurally, so the two sides can't drift — verified end to end, from a single source-of-truth definition to the backend that enforces it.
The problem: two failure modes, both common
There are two naive approaches, each failing in a predictable direction.
Frontend-only checks are insecure. If the client decides what you can do, anyone with the network tab open calls the endpoint directly. Client-side checks are UX, not a security boundary.
Backend-only checks are secure but cause drift. Put all enforcement on the server and the UI becomes a guess. It shows a page, the user fills out a form, hits save — and then gets a 403. Worse, the two sides are maintained by different people with different vocabularies: the backend has a users.write scope, the frontend hides a menu behind an isAdmin flag. Nobody renamed anything maliciously; they just evolved apart, and every release widens the gap.
The instinct is "just keep them in sync," but manual synchronization across a boundary is exactly the thing that decays. The sync has to be a property of the system, not a discipline you ask people to maintain.
The model: split ownership by what each side controls
The insight is to stop treating "permissions" as one thing and split ownership along the natural seam:
- The frontend owns pages and navigation. Which routes exist, how they're grouped, what they're called, which are read-only versus editable. The backend has no opinion on your sidebar.
- The backend owns data and actions. Which endpoints exist and what each reads or mutates. The frontend has no business asserting it.
So there are two levels of permission:
- Page-level permissions — one View and (optionally) one Manage per page. View controls whether the page appears and is reachable. Manage controls whether write actions render. Read-only pages have no Manage level.
- Service-level permissions — the granular scopes the API enforces, one per endpoint family.
The bridge is an expansion: each page-level permission declares which service-level permissions it implies. A page's View expands to the read scopes its data depends on (often a primary scope plus secondary ones, since one page reads from several services); its Manage expands to the write scopes. A generic, illustrative entry:
"/orders": {
displayName: "Orders",
view: { requires: "orders.read", alsoRequires: ["customers.read"] },
manage: { requires: "orders.write" },
}requires is the scope on the page's primary endpoint — what the server's permission check matches. alsoRequires are secondary scopes; if one fails the server returns 403 for that call and the UI degrades to an empty state. This definition lives in one frontend-owned place and is the single source of truth for the whole catalog.
How the two sides stay synchronized
A source of truth only helps if the other side reads from it. Three mechanisms keep them aligned — and crucially, they form a closed loop you can actually trace through the code.
The catalog drives the client directly. The menu builder and the route guard both read page-level permissions from the same definition — no hand-written isAdmin flags anywhere. The user's resolved permission list (returned by the backend after evaluating their role) is compared against these IDs; if the ID isn't present, the menu item disappears and the route guard redirects to an unauthorized page.
The catalog is published to the backend on startup. On boot, the app reshapes the catalog into a payload — every page, its display metadata, its page-level IDs, and its service expansions — stamps it with a content hash as a version, and publishes it to the authorization service. The publish is deliberately non-fatal: it retries with backoff but never blocks the app from booting, because a one-release-stale catalog is recoverable and a frontend that won't start is not.
The backend serves the catalog back, and the assignment UI renders from that. This is the part that proves the loop closes. When an admin builds a role, the page-permission checklist isn't rendered from the local definition — it's fetched from the backend's stored catalog, grouped and ordered by the exact metadata the frontend defined. The frontend defines the catalog; the backend stores and serves it; the frontend's role-builder consumes the backend's copy. If the sync were broken, the assignment UI would visibly diverge. It doesn't, because there's one catalog.
So the full loop: an admin assigns pages (in human terms) → the backend expands them to service scopes via the synced catalog → the user's token carries those scopes → the server enforces them per endpoint → and the same catalog already decided the page is visible on the client. The UI can't show something the server will reject, because both decisions descend from one definition.
How permissions are defined and shared — and the invariant that holds them together
Because Manage expands to write endpoints and View to read endpoints, Manage is meaningless without View — you can't write to a resource you can't read. That conceptual dependency is enforced where roles are assembled: the role builder won't let you grant Manage without View, and revoking View revokes Manage with it. The two-level model isn't just a naming convention; it's an invariant the assignment UI guarantees, so an impossible grant ("can edit but not open the page") can't be expressed in the first place.
Where enforcement actually lives: three layers, one truth
This is the part worth being precise about, because it's where "secure and drift-free" is won. Enforcement is layered, and only the innermost layer is a security boundary:
- Middleware authenticates — is there a valid session? Nothing more.
- The client route guard authorizes the page — it looks up the current route's page-level permission and checks it against the user's resolved list (case-insensitive; global admins bypass; on a failed permissions fetch it shows a blocking retry modal rather than silently granting). This prevents the user from reaching a page the backend would reject.
- The backend authorizes the action — every API call is gated by a server-side permission check on the expanded service scope. This is the real security; it would hold even if the client checks were stripped entirely.
The outer two layers are projections of the inner truth, not independent decisions. The client never makes a security call — it makes a UX call derived from the same definition the server enforces. That's why the UI never renders a door that opens onto a wall, and why removing the client checks would degrade the experience without creating a hole.
Tradeoffs and results
It isn't free. The frontend now owns something that feels like backend territory — the authoritative catalog — which only works if both teams treat the catalog as canonical and the backend treats the sync as input. Adding a page means adding a fully-specified entry, and a wrong scope is now a single clear bug instead of two vague ones (which is the point). The expansion model assumes permissions compose into View/Manage tiers; genuinely exotic per-field rules would need an escape hatch.
What you get is hard to buy any other way: the client is a faithful projection of server truth, and they cannot disagree. Security lives entirely on the server; the UI never renders an action the API will refuse; new pages are one well-typed entry; onboarding is "read the catalog." The whole failure class of "the UI let me do something the API then refused" is designed out rather than tested for.
How I'd evolve this
The pattern earns its keep, but the more interesting question is what it becomes under pressure — more endpoints, more teams, stricter compliance. Each direction is the same move: take an invariant the system currently trusts and make it something the system proves.
Move drift detection earlier in time. There's a hierarchy of when you can catch a mismatch — at the user's request (worst), at boot, in CI, or in the editor. The sync catches most of it at boot. The progression is to keep walking that timeline backward: a pipeline check that fails when an endpoint exists with no scope mapped to it, or a scope is referenced that no endpoint enforces. The cost of a divergence should fall on whoever introduced it, in the moment they did — not on a user weeks later.
Promote the contract from convention to verification. The two sides agree because they read the same definition; the last assurance is that each side interprets it identically. A mature version exercises real endpoints with precisely-scoped credentials and asserts the server's actual authorization decisions match the declared scope. A shared source of truth removes one class of drift; a shared and continuously verified one removes the subtler class where both sides cite the same name and mean different things.
Let enforcement live at every layer it can, cheaply. Security belongs on the server — non-negotiable. But the same decision can be projected further out: the page-level guard could run at the edge so an unauthorized route never ships a page shell at all. The goal is defense in depth without duplicating truth: one definition, enforced authoritatively in one place, echoed everywhere else for speed — as long as the outermost layer is never the one you rely on.
Give the irregular cases a deliberate boundary. The View/Manage tiering survives by staying coarse. The failure mode isn't that exotic requirements appear — it's that they get absorbed as ad-hoc exceptions that erode the model. Conditional access, field-level rules, and ownership scoping deserve an explicit, typed home that composes on top of the base model rather than bending it. Keep the common path boring; make the hard path visible and contained.
Treat the catalog as versioned state. The catalog already carries a content hash; the deeper version recognizes that during a rollout an old frontend and a new one briefly coexist, each with its own view of the catalog. Giving it a retained, versioned identity lets the backend resolve a user's permissions against the exact catalog their session loaded — turning a narrow deploy-window race into a lookup.
The throughline is the principle the whole design rests on, pushed harder: hold one definition, split ownership along the seam each side genuinely controls, and make every departure from that definition provable and loud — caught as early in time and as far from the user as the architecture allows.