Skip to main content
ASoc
Tutorial

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.

The ASoc Team12 min read

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

LayerQuestion it answersTrusted?Failure mode if it is the only layer
UI — hide the nav item, disable the button"Should I draw this?"NoAnyone opens devtools, or types the route
Server — reject the request"May this caller do this?"Yes, for this requestA 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:

  1. 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.
  2. The switch is exhaustive over a union, so a new grant kind is a compile error at every decision site. Roles typed as string are how a new role silently defaults to "denied" in one place and "allowed" in another.
  3. 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 definer runs the body with the owner's rights, so it can write past RLS.
  • set search_path = public, pg_temp pins 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, authenticated means 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

MistakeSymptomFix
Role stored in client state and trustedRoute works when typed into the address barClient role decides rendering only; check server-side per request
Fetching all rows then filtering in ReactData leaks in the network tab, not the UIFilter in the query; enforce with RLS so it cannot be unfiltered
RLS enabled, permissive write policies added"Enabled" on the dashboard, writable in practiceNo write policies; route writes through revoked security definer functions
security definer without set search_pathElevated function operates on a shadowed tableAlways pin the search path
security definer without revoking executeAny signed-in user calls the privileged path directlyRevoke from anon, authenticated, public
Accepting userId as a request parameterAny user reads or edits another's dataDerive identity from the verified token
Roles typed as stringNew role silently denied in one place, allowed in anotherUnion type plus an exhaustive switch
Permission logic duplicated per endpointTwo endpoints, two behaviours, one of them wrongOne pure predicate, imported everywhere
Count-then-insert limitsConcurrent requests both pass a limit of oneOne 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.

Keep reading

Tutorial8 min read

React Carousel: Two Patterns, Zero Dependencies, One Bug We Fixed

A passive CSS marquee and a navigable translated-track gallery, both dependency-free — plus the missing prefers-reduced-motion guard this audit found and fixed.

Read more
Tutorial9 min read

React Dropdown: The Two Defects a useState Toggle Ships With

A production dropdown, defect by defect: why one `open` boolean breaks the click, and why the 8px gap has to be padding rather than margin.

Read more
Tutorial9 min read

React + EmailJS: What Ships in Your Bundle vs. a Server Action

EmailJS ships its service ID, template ID and public key in your bundle by design. This storefront's contact form ships none of that — the two files that decide it.

Read more