Role-Based Access Control in a React Dashboard: Three Layers, One Authority
Six row-level policies, all of them SELECT, and no write policy for any signed-in role. Where a permission decision belongs, and why the UI check is a comment.
A role held in React state decides what to draw, never what a user may do. Access control in an admin dashboard lives in three layers — the UI hides, the server denies, and the database refuses to return the row — and only the third is authoritative, because it is the only one the client cannot reach around. Build the first two for usability; build the third so the first two can be wrong without it mattering.
This is the model behind the commerce side of this storefront: six row-level policies, all select, and not one write policy for any signed-in role. Here is how the layers divide, and what each one is allowed to be trusted for.
A note on terms before the code: this is about access control in a React admin dashboard generally — a Vite or Next.js app with tables, forms and a sidebar. It is not about the react-admin framework, which has its own authProvider and canAccess API. The layering below applies whether or not you use it; if you do, its permission hooks are layer one.
The three layers, and which one is authoritative
| Layer | Question it answers | Trusted? | Failure mode if it is the only layer |
|---|---|---|---|
| UI — hide the nav item, disable the button | "Should I draw this?" | No | Anyone opens devtools, or types the route |
| Server — reject the request | "May this caller do this?" | Yes, for this request | A second entry point (a job, another endpoint) skips it |
| Database — the row is not returned | "Does this data exist for this caller?" | Yes, unconditionally | — |
The trap is that layers one and two feel sufficient, and in a React admin the first one feels the most sufficient of all, because you can see it working. You hide the "Users" tab from a viewer, load the page as a viewer, and the tab is gone. Nothing about that observation is evidence.
A useful sentence to keep: a permission check the client performs is a rendering hint, and a rendering hint is a comment.
Layer 1: the UI check exists for the person, not the system
Hiding what someone cannot use is real work — an admin full of buttons that error is a bad product. So do it, and be explicit in the code about what it is:
/**
* Rendering convenience only: one memoized ownership lookup per page load,
* shared by every card on it. `/api/download` re-runs authorizeDownload on
* every request — this hook decides what to draw, never what is permitted.
*/
That comment is the whole discipline. We compute ownership once on the client so that a page of a hundred product cards does not issue a hundred lookups, and the comment states plainly that the real gate is elsewhere. When someone later reads the hook and wonders whether it needs hardening, the answer is written next to it: no, because nothing depends on it.
Two rules that keep layer one honest:
- Never fetch data you intend to hide. Filtering a full result set in the client means the data was already sent. The row must not arrive.
- Never derive a permission from a prop. If a component receives
canDelete, something above it decided; that decision is the thing to audit, not the button.
Layer 2: the server check, written as a pure function
The server check should be a function of facts and nothing else — no database calls, no request object, no framework types. That makes it unit-testable exhaustively and identical wherever it runs.
Ours is about thirty lines and has no imports at all:
export function slotCovers(slot: Slot, target: DownloadTarget): boolean {
if (slot.status !== "active") return false;
switch (slot.kind) {
case "all_access":
return true;
case "all_templates":
// Every premium template, all framework editions — but never the
// backend zip (that's all_access only).
return target.framework !== "backend";
case "template_single":
return (
slot.productSlug === target.productSlug &&
target.framework !== "backend"
);
}
}
export function authorizeDownload(slots: Slot[], target: DownloadTarget) {
return slots.some((s) => slotCovers(s, target));
}
Three properties are worth copying regardless of your domain:
- Grants are data, decisions are code. A user's rights are rows (
slots), not a string on their profile. Adding a plan tier is inserting rows, not editing a switch statement in three places. - The
switchis exhaustive over a union, so a new grant kind is a compile error at every decision site. Roles typed asstringare how a new role silently defaults to "denied" in one place and "allowed" in another. - Rank comparisons are explicit. Tiers that nest get a rank table and a
tierCoversTier(owned, target)helper, rather than>=on a string, which sorts"t10"before"t2".
The route handler then does one thing: establish who is calling, look up their rows, and call the function. It never re-implements the rule.
Layer 3: the database, where the query cannot be written
This is the layer that makes the other two optional, and the one most React admin tutorials skip because it lives outside the JavaScript.
Every table carries row-level security, and every policy is a select:
alter table public.profiles enable row level security;
alter table public.orders enable row level security;
alter table public.entitlement_slots enable row level security;
create policy "own profile" on public.profiles
for select using ((select auth.uid()) = id);
create policy "own orders" on public.orders
for select using ((select auth.uid()) = user_id);
create policy "own slots" on public.entitlement_slots
for select using ((select auth.uid()) = user_id);
Six policies across the schema, and all six are for select. There is no insert, update or delete policy for a signed-in user anywhere. The consequence is worth stating slowly: with a stolen access token and the public API URL — both of which are client-visible by design — an attacker can read exactly the rows they already own, and cannot write anything at all.
That is not an accident of scope. It is the rule that makes the surface auditable: the set of things a signed-in role can write is empty, so there is nothing to review.
Then how does anything get written?
Through named functions that run with elevated rights and are not callable by the client:
create or replace function public.create_order_with_slots(...)
returns table(order_id uuid, created boolean)
language plpgsql security definer set search_path = public, pg_temp as $$
...
$$;
revoke execute on function public.create_order_with_slots(...)
from anon, authenticated, public;
Three details do the work, and dropping any one of them is a hole:
security definerruns the body with the owner's rights, so it can write past RLS.set search_path = public, pg_temppins name resolution. Without it, a caller who can create objects can shadow a table name and have the elevated function operate on theirs.revoke execute ... from anon, authenticatedmeans the function is unreachable from a browser token. Server code calls it with a service key.
A security definer function that anyone may execute is not a permission boundary — it is a hole with a nice name. The revoke is the point.
The same pattern covers rate limiting: the count-and-record step is one atomic function rather than a read followed by a write, because the read-then-write version has a race that lets two concurrent requests both pass a limit of one.
Where the role comes from matters as much as where it is checked
In an admin, the caller's identity must come from a verified token, not from a session object the client can influence and not from a parameter. On the server we read verified claims (getClaims()) rather than the convenience method that returns whatever session is at hand — the difference is signature verification, and it is the difference between an identity and a suggestion.
Two derived rules:
- Never accept a user id as an argument. If an endpoint takes
userId, it has a horizontal privilege-escalation bug regardless of what else it checks. Take the id from the verified token. - A mutation endpoint is public. This applies to server actions and route handlers alike — both compile down to a POST endpoint anyone can call. "Only our admin calls this" is not a control.
Wiring this into a React admin with no server of its own
A Vite React admin has no server layer, which sounds like it removes layer two. It does not — it moves it. The two workable shapes:
Database-only. The React app talks to the database directly with the user's own token, and RLS is the entire authorization system. This works well and is the reason RLS exists, but it means every rule must be expressible as a SQL predicate, and privileged writes still need security definer functions plus a service key — which cannot live in the browser, so anything privileged needs a function endpoint anyway.
A thin API in front. One deployed function per privileged operation, holding the service key, calling the same pure predicate the client uses to decide what to render. This is the shape we run, and the reason slotCovers has no imports: the identical function informs the UI and decides the request, with no chance of the two drifting apart.
Either way the React side is unchanged, and so is the rule: the client's copy of the rule decides pixels.
Mistakes and how they show up
| Mistake | Symptom | Fix |
|---|---|---|
| Role stored in client state and trusted | Route works when typed into the address bar | Client role decides rendering only; check server-side per request |
| Fetching all rows then filtering in React | Data leaks in the network tab, not the UI | Filter in the query; enforce with RLS so it cannot be unfiltered |
| RLS enabled, permissive write policies added | "Enabled" on the dashboard, writable in practice | No write policies; route writes through revoked security definer functions |
security definer without set search_path | Elevated function operates on a shadowed table | Always pin the search path |
security definer without revoking execute | Any signed-in user calls the privileged path directly | Revoke from anon, authenticated, public |
Accepting userId as a request parameter | Any user reads or edits another's data | Derive identity from the verified token |
Roles typed as string | New role silently denied in one place, allowed in another | Union type plus an exhaustive switch |
| Permission logic duplicated per endpoint | Two endpoints, two behaviours, one of them wrong | One pure predicate, imported everywhere |
| Count-then-insert limits | Concurrent requests both pass a limit of one | One atomic function that counts and records together |
Frequently asked questions
Is RBAC or ABAC the right model for an admin dashboard? Start with roles, and let attributes in only where a role genuinely cannot express the rule — usually ownership ("this record belongs to this user"), which RLS handles natively as a predicate. Full attribute-based policy engines are worth their complexity when rules are configured by customers rather than by you.
Do I need row-level security if every query goes through my API? You do not need it for the requests you wrote. You need it for the ones you write next year, the background job someone adds, and the analytics tool someone points at the database. RLS is the layer that keeps being correct when the assumption "everything goes through the API" quietly stops being true.
How do I test authorization properly? Two levels. Unit-test the pure predicate over the full matrix of grant × target — cheap, exhaustive, fast. Then integration-test with a real non-privileged token that the forbidden read returns zero rows and the forbidden write is rejected. Testing only through your own UI tests the UI.
What about the admin's own super-user role?
Give it its own grant kind rather than a bypass. A grant that returns true early is still a row in the same table, so it is revocable, auditable, and cannot be conjured by editing a claim. A hard-coded email allowlist in application code is the version of this that goes wrong.
Does hiding UI still matter if the server enforces everything? Yes, for product reasons rather than security ones. An interface full of controls that fail is worse than one that shows what is available. Just never let the hiding be load-bearing.
React admin templates with the layout already built
Authorization is the part you should write yourself against your own data model — the layout, navigation, tables and forms around it are not. The React admin templates below ship the shell this pattern drops into: an accessible sidebar, data tables, form patterns and auth screens, with the permission decisions left where they belong.
