13 min read
Implemented in Checkout Kit
Full-stack Stripe checkout starter: product catalog, cart, orders, and admin — React + Go or Node + MongoDB
See it running — Checkout Kit demoThe 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.
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:
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.
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);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.
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,
);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();
};
}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.
Now the frontend renders from the same engine and the same vocabulary — resource 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>or imperatively, when you need the boolean:
const canRefund = useCan({ action: 'refund', resource: 'order' });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.
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,
}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>Same condition, both sides. The UI stops offering the button on someone else's record; the server refuses the request if it arrives anyway.
<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.
resource × action, explicit deny wins.createRbacEngine), the same policy on the client and the server.rbacGate(engine, resource, action) on every mutating route, returning 403.<Can> / useCan from the same policy, hiding or disabling.condition on the permission, with the same resourceData shape on both sides.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.