Blog
4 min read

Role-Based Access Control (RBAC) for Your App: A Practical Guide

How to add roles and permissions to a web app without making a mess: roles vs permissions, a simple database schema, checking permissions on the server, multi-tenant roles per organisation, enforcing in the UI and the API, and testing it.

Sooner or later, "logged in" isn't enough. Some users should manage billing, some should only view reports, admins can delete things. Role-based access control (RBAC) is the standard way to organise this: users get roles, roles grant permissions, and your code checks permissions.

(First, make sure the distinction is clear: authentication vs authorization.)

Roles vs permissions

  • A permission is a specific action: invoices:read, invoices:create, members:invite, billing:manage.
  • A role is a named bundle of permissions: viewer, member, admin, owner.
owner   → everything
admin   → members:invite, invoices:*, settings:edit
member  → invoices:read, invoices:create
viewer  → invoices:read

Check permissions in code, not roles. Write can(user, "invoices:create"), not if (user.role === "admin" || user.role === "owner" || ...). When you add a role or change what one can do, you edit one mapping — not fifty if statements scattered through the codebase.

Start simple

For most apps, a fixed set of roles defined in code is plenty:

const ROLE_PERMISSIONS = {
  owner:  ["*"],
  admin:  ["members:invite", "invoices:read", "invoices:create", "invoices:delete", "settings:edit"],
  member: ["invoices:read", "invoices:create"],
  viewer: ["invoices:read"],
} as const;

type Role = keyof typeof ROLE_PERMISSIONS;

export function can(role: Role, permission: string) {
  const perms: readonly string[] = ROLE_PERMISSIONS[role];
  return perms.includes("*") || perms.includes(permission);
}

Store only the user's role in the database. Move permissions into the database only if customers need to define their own custom roles — that's a much bigger feature.

Roles in a multi-tenant app

In apps where users belong to organisations or teams, a role isn't a property of the user — it's a property of their membership in a particular organisation. Ana can be an owner of her own company and a viewer in a client's.

CREATE TABLE memberships (
  user_id bigint NOT NULL REFERENCES users(id),
  org_id  bigint NOT NULL REFERENCES organizations(id),
  role    text   NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
  PRIMARY KEY (user_id, org_id)
);

Every permission check then has three inputs: who (user), where (organisation), what (permission). (Primary key vs foreign key explains the composite key; multi-tenant SaaS on Postgres goes deeper.)

Enforce on the server, every time

async function requirePermission(req, orgId: string, permission: string) {
  const membership = await db.membership.findUnique({
    where: { user_id_org_id: { user_id: req.user.id, org_id: orgId } },
  });
  if (!membership || !can(membership.role, permission)) {
    throw new HttpError(403, "Forbidden");
  }
  return membership;
}

app.delete("/api/orgs/:orgId/invoices/:id", async (req, res) => {
  await requirePermission(req, req.params.orgId, "invoices:delete");
  await db.invoice.deleteMany({
    where: { id: req.params.id, org_id: req.params.orgId }, // scope to the org too
  });
  res.status(204).end();
});

Two checks happen here, and both matter:

  1. Does this user have the permission in this org? (RBAC)
  2. Does this invoice belong to this org? (ownership — skipping it is the IDOR bug.)

req.user.id comes from the server-side session. The role comes from the database, not from anything the client sent.

The UI is for convenience, not security

Hide the "Delete" button from viewers — good UX. But hiding a button protects nothing: anyone can send the API request directly. Every permission must be enforced on the server. Expose the user's permissions to the frontend (for example in a /api/me response) so the UI can adapt, but never trust the frontend's decision.

Special cases to decide up front

  • The last owner. Prevent removing or demoting the only owner of an organisation.
  • Privilege escalation. An admin shouldn't be able to make themselves (or anyone) an owner unless that's allowed. Check who can assign which roles.
  • Self-service. Can members leave on their own? Can admins remove other admins?
  • Role changes take effect immediately. If roles are cached in a long-lived token, a demoted user keeps their old powers until it expires. (Session vs JWT.)
  • Audit trail. Log role changes and sensitive actions: who, what, when. (Soft deletes and audit logs.)

Database-level enforcement (optional)

If your frontend talks to the database directly (as with Supabase), the same rules must exist as row-level security policies, since there's no API layer to check them. (Supabase RLS explained.) Even with an API, RLS can be a useful second layer.

Test it like an attacker

Write tests that log in as each role and try every protected action — expecting 403 where it should be denied. A small table-driven test covering role × action catches most mistakes, and it's exactly the kind of test AI tools write well if you give them the role matrix. (Getting AI to write tests that actually catch bugs.)

The summary

  • Users have roles; roles grant permissions; code checks permissions.
  • Keep roles in code until customers truly need custom roles.
  • In multi-tenant apps, roles belong to memberships, per organisation.
  • Enforce on the server every time — and also check the record belongs to the org.
  • Hiding buttons is UX, not security. Test every role against every action.

EasySpawn gives Claude Code a running app and database to test against — logging in as each role and confirming the API refuses what it should, before anything ships. See how it works or join the waitlist.

Related: Designing a REST API That Won't Embarrass You Later · Validating Input With Zod · OWASP Top 10 Explained · What Is CRUD?

Keep reading