Skip to Content
AuthorizationNext.js (React Server Components)

Next.js (React Server Components)

@evanion/acl 0.1.0 and its patch releases up to 0.1.2, the version on npm. Values on this page were produced by running the source at 40b8f84, a later commit than the 0.1.0 tag, and are not re-executed.

In a Next.js application, authorization happens at the trust boundary. When a request hits the server, your server components and Server Actions act as the final gatekeepers. Because they run in a trusted Node.js process, they can call @evanion/acl’s access.can to enforce rules before any data is rendered or any write is performed.

Adopting a shared policy document

In a microservices architecture, your frontend doesn’t usually author the rules; it adopts a document published by a backend service.

In the Baize storefront (apps/storefront-rsc), the app fetches a PolicyDocument (containing a version and the matrix) from the shop-api. It then uses parseMatrix to create an evaluator.

let adopted: { version: string | number | undefined; access: ShopAccess } | undefined; /** * The evaluator over `document`, parsed only when the version moved. * * `parseMatrix` and not `hydratePolicy`: a key the document does not carry, * such as the internal `inventory.read`, refuses with `unknown-action` where * `hydratePolicy` would throw mid-render. * * The cast states this app's subject and row types. `parseMatrix` reads the * document at runtime, so it returns an `Access` over plain records, and * TypeScript cannot check that against `ShopAccess`. The document's `schema`, * which `parseMatrix` checks every condition against, is what keeps shop-api's * rows and these types in step. */ function adopt(document: PolicyDocument): ShopAccess { if (adopted && adopted.version === document.version) return adopted.access; const access = parseMatrix(document.matrix) as unknown as ShopAccess; adopted = { version: document.version, access }; return access; }

Why parseMatrix instead of hydratePolicy?

When you adopt a document from another process, you use parseMatrix to enter closed mode. In closed mode, if a request asks about a permission key that the document doesn’t carry, the engine returns unknown-action and refuses. Using hydratePolicy in this scenario would cause the app to throw an error mid-render, which would crash the page.

Efficiency through versioning

To avoid the cost of validating and deep-freezing the matrix on every single request, the app keys its evaluator by version. If the fetched version matches the held version, the app re-uses the existing evaluator, skipping the expensive setup process.

Request-scoped authorization

To ensure that every component on a page uses the same subject and the same instant in time, the app scopes the evaluator to the request.

export const currentAccess = cache(async (): Promise<ShopAccess> => adopt(await fetchJson<PolicyDocument>('/policy')), ); /** * The matrix bound to this page view's subject and one clock instant. * * Bound once so every widget decides against the same actor at the same * instant. Two widgets settling their own `now` a few milliseconds apart would * be able to disagree about a rule with a time window in it. * * This is the handle the widgets call, and it is the app's whole enforcement * story on the render side. shop-api decides again on its own copy of the same * document for every request this app sends it, so a widget that decided wrong * is a rendering bug and not an access-control one. */ export const authorized = cache(async (): Promise<Authorized<ShopObjects>> => { const [access, subject] = await Promise.all([ currentAccess(), currentSubject(), ]); return access.authorize(subject); });

By wrapping the logic in React’s cache, the app ensures that GET /api/policy is called only once per render, regardless of how many components (like Catalogue or Spotlight) need to check permissions.

The lifecycle of a policy document during a page view.

Gating the render and the fetch

In a Server Component, you can decide whether to render a piece of UI before you even fetch the data it needs.

For example, the Activity component might list recent events, but only for managers. By calling can() before the fetch, you prevent the server from pulling sensitive data from the API if the user isn’t authorized to see it:

// Decided before the fetch. A gate that renders nothing over data it already // asked for has still pulled the events into this process. const may = await authorized(); if (!may.can('telemetry', 'read').allowed) return null; const events = await fetchJson<TelemetryEvent[]>('/telemetry');

Conversely, if a rule depends on the object’s data (like checking if a user owns a specific game), you must fetch the row first, then decide.

const may = await authorized(); if (!may.can('game', 'read').allowed) return null; const [game, stock] = await Promise.all([ fetchJson<Game>(`/games/${urnPath(urn)}`), fetchJson<Stock>(`/inventory/${urnPath(urn)}`), ]); const mayReprice = may.can('game', 'reprice', game).allowed;

The two boundaries: Render and Action

A Next.js route has two independent trusted entry points: the Render (Server Component) and the Server Action.

BoundaryRuntimeRole
Server ComponentTrustedDecides what is rendered; hides buttons or sections.
Client ComponentBrowserAdvisory only; toggles UI based on the render’s verdict.
Server ActionTrustedDecides independently if a write is allowed before committing to DB.
The dual-gate architecture. A page view runs the server component to decide what to render. A direct post to a Server Action skips the render entirely, requiring the action to re-resolve the subject and re-evaluate the rules.

Why Server Actions must decide again

Even if the render hid a “Delete” button from the user, a malicious actor can post directly to the Server Action endpoint using curl or a script. Therefore, the Server Action must never trust the render’s verdict; it must re-resolve the subject, re-read the row, and decide again.

import { , , type } from '@evanion/acl'; type = { : string; : string[]; : string }; type = { : string; : string; : string; : number }; const = < , { : }, { : | 'declare' } >() .('game', () => .( 'declare', .('subject.roles', 'operator'), .('object.shop', 'subject.shop'), ) .({ : ['availability'] }), ) .(); const = new <string, >([ [ 'urn:game:wingspan', { : 'urn:game:wingspan', : 'stockholm', : 'in-stock', : 59900, }, ], [ 'urn:game:gloomhaven', { : 'urn:game:gloomhaven', : 'gothenburg', : 'in-stock', : 149900, }, ], ]); // Stands in for reading the verified session, which Next.js reaches through // `cookies()`. const = async (): <> => ({ : 'staff:ines', : ['operator'], : 'stockholm', }); async function (: FormData): <void> { 'use server'; const = await (); const = .((.('urn'))); if (!) throw new ('not-found'); // Whatever the sender put a name on arrives here, `urn` and `price` too. const = .() as <>; const = .( , 'game', 'declare', , 'write', , ); if (!..) throw new ('forbidden'); .(., { ..., ...(, ), }); } // Two submissions straight to the action, with no render before either. const = (: <string, string>) => { const = new (); for (const [, ] of .()) .(, ); return ().( () => 'written', (: Error) => ., ); }; const = await ({ : 'urn:game:wingspan', : 'preorder', : '1', }); ; // -> 'written' .('urn:game:wingspan')?.; // -> 59900 const = await ({ : 'urn:game:gloomhaven', : 'preorder', }); ; // -> 'forbidden'

Client-side UI toggles

To provide a snappy UI, you can pass the access.matrix (but never the access evaluator itself, as functions cannot be serialized) to the client.

The client rebuilds the evaluator using parseMatrix and wraps the tree in the @evanion/react-acl provider. This allows client components to use the useCan hook to hide or show elements instantly. However, this is purely advisory; the actual enforcement always happens on the server.

Where to go next

Last updated on