Skip to Content
React AuthorizationWhich Side Decides

Which side decides

Not on npm yet

npm install @evanion/react-acl does not resolve. The package is private: true, so a release run versions and tags it without publishing: npm cannot configure a trusted publisher for a package that does not exist on the registry, and the first version has to go up by hand.

The example on this page is a region of libs/react-acl/examples/ and runs under nx test @evanion/react-acl.

The concept. @evanion/react-acl runs in the shopper’s browser and toggles controls.

What you get. A trusted runtime that makes the decision that counts, and a browser copy that decides what the screen shows.

Why you want it. A shopper edits the JavaScript, so a control the browser hid is a control the server still has to refuse.

How the library gets you there. The package ships 'use client' on its entry point, so a server component importing useCan gets a client reference, and calling it during the server render throws.

Where the line through your app falls

@evanion/react-acl runs in the shopper’s browser and toggles controls. The trusted side, a loader, a server component, an action or a route handler, calls @evanion/acl and makes the decision that counts. A decision the browser makes changes which controls render and nothing else, and the same can runs again in that trusted runtime before anything is written. 'use client' sits at the top of the package’s entry point, so a hook cannot be called on the trusted side at all.

useCan and access.can both compile, both return a Decision, and neither call says which side enforces. A shopper can work around the browser’s answer and cannot touch the handler’s.

One decision, two runtimes

useCan('listing', 'edit', listing) and access.can(shopper, 'listing', 'edit', listing) evaluate the same rules over the same document and return the same Decision. can is a function over frozen JSON and does not know where it is running, so the library draws no distinction between the two calls.

The runtime separates them. One call ran where the shopper could not reach the code, and one ran in the shopper’s browser, over a document the shopper can read, against a subject the shopper’s own page supplied.

WhereRuntimeDecides?
Middlewaretrustedyes, for what it lets through
Loader or server componenttrustedyes, for what it returns or renders
PolicyProvider and its treebrowsernever; it toggles what the shopper sees
Action, Server Action, route handlertrustedyes, independently, for what it writes

A caller reaches the last row of that table directly. curl posts to the action without rendering anything, so the handler re-resolves the subject from the session, re-reads the listing from the database, and decides again. No layer may lean on “already checked upstream”. Every app in the chain runs its own evaluation and trusts no earlier layer, and that holds between two of your own services as firmly as it holds between the browser and the server.

Each of those evaluations reads a document already in memory. can is a function over frozen JSON, so nothing in the trusted column waits on a network call to decide. A policy service that is slow or down cannot turn a deny into an allow.

What a server component calls

A loader or a server component calls access.can from @evanion/acl and imports nothing from @evanion/react-acl. The loader below asks 'read' before it renders anything, and the Edit button’s question is the same call with 'edit':

import type { , } from '@evanion/acl'; type = { : string; : string; : 'draft' | 'published' }; export function ({ , , , }: { : ; : ; : ; }) { const = .(, 'listing', 'read', ); if (!.) return <>No such listing.</>; return <>{.}</>; }

ListingPage takes access as a prop here so the example runs under a test. An app builds the evaluator at module scope in a server-only module and calls access.authorize(shopper) once per request, which drops the subject from every signature after it. React Router 8 and React Server Components each work that through against a real app’s file layout.

@evanion/react-acl cannot run in a React Server Component (RSC) at all. Its entry point carries 'use client' and calls createContext, and the react-server condition Next resolves React under for an RSC exports no createContext and no stateful hook. An RSC app calls access.can(...) from @evanion/acl directly.

Import the package into an RSC file anyway and the 'use client' directive opens a client boundary around whatever imported it, so the component you wanted to keep on the server is sent to the browser with its props serialized.

What crosses the render

Access is a set of closures over a frozen document, and it does not survive serialization. JSON.stringify drops every method silently, so no loader return value and no server component prop can carry one.

The matrix crosses instead: a JSON envelope carrying its own version and schema, which is why nothing has to travel beside it. The client calls hydratePolicy on it and gets its own evaluator back, which is the access prop Getting started mounts.

What crossesThe form it crosses inWhat the browser does with it
Accessnothing; closures do not serializerebuilds its own with hydratePolicy
the matrixa JSON envelope carrying version and schemahydrates it into an evaluator
the subjecta plain object, and no credentialevaluates against it
nowan ISO stringkeys every hook memo on it

The subject crosses too, because the browser needs it to evaluate. It is not a credential and nothing reads it back. On the next request the server resolves its own subject from the session, and it never trusts the copy it sent.

now crosses as a string. Resolve it on the server so the server render and the client’s first render agree on the instant, and so a time window in the policy is not read against a clock the shopper’s machine sets.

The trusted side decides and renders the matrix, the subject and the instant into the page. The browser rebuilds an evaluator with hydratePolicy, and its hooks only toggle controls. The action or route handler decides again for itself, and a caller reaches that handler without rendering anything.

What the browser’s copy is worth

The matrix in the browser is as fresh as the render that delivered it. Revoke a permission and that copy keeps granting until something replaces it, and it has no way to notice. access.version (the revision stamped on the matrix) is the surface for detecting the gap. Your app writes the part that fails closed and refetches.

The server never reads the browser’s copy

The server never reads the browser’s copy. Every loader, every action and every handler evaluates its own.

Treat the browser’s answer as a convenience for toggling what a control looks like, and nothing else. A shopper shown an Edit button that 403s has been misled, and the hooks exist to prevent that. Everything that protects the listing happens on the trusted side, in every layer, each one deciding for itself.

Where to go next

  • Security contract — the whole trust boundary, time of check to time of use, and what remains yours
  • React Router 8 — middleware, loader, action, and the provider in the shell
  • React Server Components — the server component, the client boundary, and the Server Action behind the button
  • API reference — the provider, the four hooks and the typed policy factory
Last updated on