DEV Community

Cover image for RBAC in React and Node: enforce the same policy on both sides
kensaadi
kensaadi

Posted on

RBAC in React and Node: enforce the same policy on both sides

The advice is everywhere: the frontend is UX, authorization belongs on the backend. It is true, and on its own it is useless — because it stops exactly where the hard part starts. Hide a button in the UI and forget the server, and the action is a curl away. Protect the server and hardcode the UI, and the two drift apart the first time the rules change. The question nobody answers is how to keep the button you hid and the endpoint you protected agreeing with each other.

The UI is an affordance, the server is the boundary

They are two different jobs. The UI decides what to show — hide or disable a control the user can't use. That is UX. The server decides what to allow — reject the request. That is security. You need both, and they must agree, but only one of them is the security boundary:

  • UI only → no security. The endpoint is open; the hidden button is theatre.
  • Server only → the app offers buttons that answer 403.

The mistake is not doing one of them. It is doing them from two different sources of truth, so they fall out of sync the moment someone edits a rule.

One policy, as data

Define the rules once. A policy is roles mapped to permissions — a resource × action pair, allow or deny:

import { createRbacEngine, type RbacPolicy } from '@dashforge/rbac';

const policy: RbacPolicy = {
  roles: [
    { name: 'admin', permissions: [{ resource: '*', action: '*' }] },
    { name: 'sales', permissions: [
      { resource: 'order', action: 'read' },
      { resource: 'order', action: 'refund', effect: 'deny' }, // explicit deny
    ] },
    { name: 'customer', permissions: [
      { resource: 'self', action: 'update' },
    ] },
  ],
};

const engine = createRbacEngine(policy);
Enter fullscreen mode Exit fullscreen mode

An explicit deny overrides an allow, so you can grant broadly and carve out the exceptions. The engine is a pure function of (roles, resource, action) → decision — no framework, no I/O — which is exactly why the same policy can run on the server and in the browser.

The server: the boundary

Every mutating route consults the engine before the controller runs. The 403 is the thing that actually stops the request:

router.patch('/customer/me',
  requireAuth(secret),
  requireRole('customer'),
  rbacGate(engine, 'self', 'update'),   // resource, action
  controllers.customer.updateMe,
);
Enter fullscreen mode Exit fullscreen mode
export function rbacGate(engine, resource, action) {
  return (req, res, next) => {
    if (!req.auth) {
      return res.status(401).json({ error: 'unauthenticated' });
    }
    if (engine.check(req.auth.roles, action, resource) !== 'allow') {
      return res.status(403).json({ error: 'forbidden' });
    }
    next();
  };
}
Enter fullscreen mode Exit fullscreen mode

This is the line that matters. Delete every check in the UI and the app is still secure. Delete this one and no amount of hidden buttons will save you.

The UI: the affordance, from the same policy

Now the frontend renders from the same engine and the same vocabularyresource and action — so it can never offer what the server forbids:

<Can action="refund" resource="order" fallback={<DisabledButton>Refund</DisabledButton>}>
  <RefundButton orderId={order.id} />
</Can>
Enter fullscreen mode Exit fullscreen mode

or imperatively, when you need the boolean:

const canRefund = useCan({ action: 'refund', resource: 'order' });
Enter fullscreen mode Exit fullscreen mode

The refund control disappears for a sales user because the policy denies order:refund — and if they forge the request anyway, rbacGate answers 403. Same rule, two enforcement points, one source of truth.

Ownership: "their own", not "any"

Most real rules are not can a customer update a customer but can a customer update **their own* record*. That is a condition on the permission, evaluated against the resource:

{
  resource: 'self',
  action: 'update',
  condition: ({ subject, resourceData }) =>
    subject.id === (resourceData as { ownerId: string })?.ownerId,
}
Enter fullscreen mode Exit fullscreen mode

The server passes the loaded record as resourceData; the UI passes the same shape:

<Can action="edit" resource="order" resourceData={{ ownerId: order.ownerId }}>
  <EditButton />
</Can>
Enter fullscreen mode Exit fullscreen mode

Same condition, both sides. The UI stops offering the button on someone else's record; the server refuses the request if it arrives anyway.

The honest part

<Can> and useCan are UX — they never make anything safe. The only thing that makes the app safe is rbacGate on the server; the UI just stops users from walking into a 403. If you ever have to choose, protect the server. The reason to share the policy is not to promote the UI into a security layer — it is to stop the UI and the server from disagreeing as the rules change.

The checklist

  • One policy, as data: roles → permissions, resource × action, explicit deny wins.
  • One engine (createRbacEngine), the same policy on the client and the server.
  • The server is the boundary: rbacGate(engine, resource, action) on every mutating route, returning 403.
  • The UI is the affordance: <Can> / useCan from the same policy, hiding or disabling.
  • Ownership as a condition on the permission, with the same resourceData shape on both sides.
  • Never ship a UI-only check and call it access control.

The engine is @dashforge/rbac — open source, on npm, framework-free. createRbacEngine(policy) is a pure function you run unchanged in React and in your API. The Checkout Kit demo wires the whole loop end to end — the rbacGate middleware and <Can> in the UI, mirrored in the Go and Node editions.

Originally published on dashforge-ui.com.

Top comments (0)