Authorization Design: RBAC, ABAC, and Permission Models That Scale
A practical guide to authorization design: tenant-scoped RBAC, ABAC conditions, ReBAC sharing, and Postgres row-level security, with real code.

The worst access-control bug I've ever shipped didn't involve a clever exploit. A customer emailed support asking why they could see another company's invoices. Not all of them, just the ones in a list view that someone had added a "status" filter to the week before. The single-invoice endpoint checked invoice.tenantId === user.tenantId. The new list query didn't. Nobody forgot on purpose. The check lived in one route handler, the new query lived in another, and there was no single place in the code that said "this is what it means to be allowed to read an invoice."
That's the pattern behind almost every authorization problem I've seen since. Authentication gets a library, a provider, and a security review. Authorization gets an if (user.role === 'admin') in whatever file happened to need it, and then another one, and then forty more. It works until the product grows a second kind of customer, a third kind of user, or a sales deal that needs "managers can approve, but only for their own region, and only under a certain amount."
This post is about authorization design: how to pick between RBAC, ABAC, and relationship-based permission models, how to combine them without building a mess, and how to structure the code and the database so the rules live in one place and still hold up when the product grows. I'll show the schema, the policy code, and the Postgres row-level security I actually use, plus the mistakes that taught me why each piece is there.
Quick answer
Good authorization design starts with tenant-scoped RBAC (roles are bundles of named permissions like invoice:approve, assigned per tenant), then adds ABAC conditions only where a rule depends on attributes of the resource or the request, such as ownership, status, amount, or region. If users share individual objects with each other (documents, folders, projects), add relationship-based access control (ReBAC) for those objects instead of inventing more roles. Whatever mix you choose, put every decision behind one can(subject, action, resource) function, deny by default, and enforce the same rules on list queries and in the database, not just on single-object endpoints.
What authorization design actually covers
Authentication answers "who are you?" Authorization answers "now that I know who you are, are you allowed to do this specific thing to this specific object, right now?" They're different problems, and conflating them is how you end up with an app that has excellent login security and an IDOR bug on every other endpoint.
Authorization design is the set of decisions behind that second question:
- The model. How permissions are expressed: roles (RBAC), attribute rules (ABAC), relationships between users and objects (ReBAC), or some combination.
- The decision point. Where the question "is this allowed?" gets answered. In security literature this is the Policy Decision Point (PDP).
- The enforcement points. Every place that has to ask the question and respect the answer: API handlers, background jobs, list queries, exports, the database itself. These are Policy Enforcement Points (PEPs).
- The data. Where roles, grants, and attributes are stored, and how fresh they need to be when a decision is made.
If you get the model wrong, you'll feel it as role explosion or unreadable rules. If you get the decision point wrong, you'll feel it as rules that disagree with each other. If you miss an enforcement point, you'll feel it as a data leak.
The problem: how permission systems rot
Nobody designs a bad permission system on day one. It decays in a very predictable order, and I've watched the same sequence play out on three different products.
Stage one: a boolean. isAdmin on the user row. Admins can do everything, everyone else can do the normal stuff. Perfectly reasonable for a product with one kind of customer.
Stage two: a role string. Someone needs a "viewer" who can't edit. role becomes 'admin' | 'member' | 'viewer', and checks like if (user.role !== 'viewer') start appearing in handlers. Still fine-ish.
Stage three: role explosion. Sales closes a customer who wants "billing admins" who can manage invoices but not users. Then "support agents" who can read everything but change nothing except ticket status. Then "regional managers." Each new requirement adds a role, and every existing check has to be revisited to decide whether the new role passes it. Usually half of them don't get revisited.

Stage four: conditions leak into code. "Managers can approve expenses, but not their own, and only up to their limit." That doesn't fit into a role, so it becomes an if inside the approval handler. A month later someone builds a bulk-approve endpoint and copies half of that if.
Stage five: the list query leak. Single-object endpoints have checks. List endpoints, search, CSV export, and the analytics query someone wrote for the dashboard don't, because filtering a list is a different shape of code than checking one object. This is where the invoice story at the top came from.
By stage five, nobody can answer "who can approve an invoice?" without grepping the codebase, and the honest answer is "depends which endpoint you call." That's the actual problem good authorization design solves. It's not about picking the trendiest acronym. It's about having one answer to that question.
RBAC, ABAC, and ReBAC in plain terms
Before the implementation, a short, honest version of the three models. You'll almost certainly end up using more than one.

Role-based access control (RBAC)
Users get roles, roles get permissions, and a check asks "does any of this user's roles include this permission?" The important detail most implementations skip: roles should map to permissions, not be checked directly in code. Code should ask can('invoice:approve'), never role === 'finance_manager'. That one indirection is what lets you add a new role later without touching a single handler.
RBAC is easy to reason about, easy to show in an admin UI, and easy to audit ("list everyone with user:delete"). It falls down when the answer depends on the specific object or the specific moment.
Attribute-based access control (ABAC)
Decisions are computed from attributes: of the user (department, region, approval limit, MFA status), the resource (owner, status, amount, classification), the action, and the environment (time, IP range, device). NIST's guide on ABAC (SP 800-162, linked at the end) describes it formally, but in practice it's "a rule that reads properties instead of just roles."
ABAC handles the "only their own, only while draft, only under their limit" requirements cleanly. The cost is that rules are code, and code is harder to audit than a role matrix. You can't easily answer "who can approve invoice 42?" without evaluating the rule against every user.
Relationship-based access control (ReBAC)
Access follows relationships between objects: Alice is an editor of folder 7, document 42 lives in folder 7, therefore Alice can edit document 42. Team "eng" is a viewer of the project, Bob is a member of team "eng", therefore Bob can view the project. This is the model behind Google's Zanzibar paper and the open-source systems inspired by it, like OpenFGA and SpiceDB.
ReBAC is the right tool when users share individual objects with each other. Trying to express "Alice shared this one document with Bob" in RBAC means creating a role per document, which is role explosion in its purest form.
The solution: one decision point, many enforcement points
The architecture that has held up for me looks like this. The model can be RBAC, ABAC, ReBAC or all three; the shape around it stays the same.
Request (user, action, resource id)
│
▼
API handler / job / export ← enforcement point: loads resource, asks the question
│
▼
can(subject, action, resource, context) ← single decision point
│
├── tenant boundary check (always first)
├── RBAC: does a role grant the permission?
├── ABAC: do the resource/context conditions hold?
└── ReBAC: is there a relationship path? (shared objects only)
│
▼
allow / deny (+ reason, logged)
│
▼
Database row-level security ← last line of defense if the app layer misses a check
Three properties matter more than the specific model:
- Deny by default. If no rule explicitly allows an action, it's denied. An unknown action string returns false, not an exception that someone wraps in a try/catch that returns true.
- The tenant boundary is checked before anything else. No role, attribute, or relationship should ever be able to cross a tenant. Make it structurally impossible, not a rule someone has to remember.
- Reads are filtered with the same rules as single-object checks. If the policy says you can read an invoice when X, the list query must apply X too. This is the one people miss.
What you need before you start
- A clear list of the resources in your product (invoice, project, document, user) and the actions on each. A spreadsheet is fine.
- A relational database. Every example here is PostgreSQL 15 or newer, because I use
unique nulls not distinctand row-level security. Supabase works as-is. - A backend in TypeScript or similar where you can put a shared policy module that every handler imports.
- An existing authentication layer that gives you a trusted user id on each request. Authorization starts after authentication, never instead of it.
Step-by-step implementation
Step 1: Name permissions as resource and action, not as roles
Before any schema, write down permissions as resource:action strings. This list becomes the vocabulary every other piece of the system uses.
invoice:read
invoice:create
invoice:update (own drafts, see ABAC below)
invoice:update_any (anyone's drafts)
invoice:approve
invoice:export
user:invite
user:manage_rolesTwo rules I follow. First, permissions describe capabilities, not job titles: invoice:approve, never finance_manager. Second, if an action has a meaningfully more dangerous variant, give it its own permission (update vs update_any, export separately from read). Bulk export is a different risk from viewing one record, and you'll want to grant them separately eventually.
Step 2: Store tenant-scoped roles in the database
The single most common RBAC mistake in B2B products is making roles global. A user can be an admin in their own company and a viewer in a client's workspace, so the role assignment must be per user, per tenant.
create table permissions (
key text primary key, -- 'invoice:approve'
description text not null
);
create table roles (
id uuid primary key default gen_random_uuid(),
tenant_id uuid, -- null = built-in role every tenant can use
name text not null,
unique nulls not distinct (tenant_id, name)
);
create table role_permissions (
role_id uuid not null references roles(id) on delete cascade,
permission_key text not null references permissions(key) on delete cascade,
primary key (role_id, permission_key)
);
create table memberships (
user_id uuid not null,
tenant_id uuid not null,
role_id uuid not null references roles(id) on delete cascade,
primary key (user_id, tenant_id, role_id)
);
create index memberships_tenant_idx on memberships (tenant_id);
-- per-user attributes that ABAC rules read later (Step 4)
create table tenant_users (
user_id uuid not null,
tenant_id uuid not null,
approval_limit_cents integer not null default 0,
primary key (user_id, tenant_id)
);Built-in roles (tenant_id is null) cover the common cases. Tenant-specific roles let a large customer define "Regional Approver" without you shipping a deploy. That flexibility is also a footgun: nothing above stops a membership in tenant A from pointing at a custom role that belongs to tenant B. Enforce that in the database, not in the admin UI:
create or replace function check_membership_role_tenant()
returns trigger language plpgsql as $$
begin
if not exists (
select 1 from roles r
where r.id = new.role_id
and (r.tenant_id is null or r.tenant_id = new.tenant_id)
) then
raise exception 'role % does not belong to tenant %', new.role_id, new.tenant_id;
end if;
return new;
end;
$$;
create trigger membership_role_tenant
before insert or update on memberships
for each row execute function check_membership_role_tenant();Now a single function answers the RBAC part of every question:
create or replace function has_permission(p_user uuid, p_tenant uuid, p_perm text)
returns boolean language sql stable as $$
select exists (
select 1
from memberships m
join role_permissions rp on rp.role_id = m.role_id
where m.user_id = p_user
and m.tenant_id = p_tenant
and rp.permission_key = p_perm
);
$$;Step 3: Put every decision behind one can() function
In the application, load the user's permissions for the current tenant once per request, then route every check through a single module. The type system does a lot of work here: a typo in a permission string becomes a compile error instead of a silent false.
// policy/types.ts
export type Permission =
| 'invoice:read' | 'invoice:create' | 'invoice:update' | 'invoice:update_any'
| 'invoice:approve' | 'invoice:export'
| 'user:invite' | 'user:manage_roles';
export interface Subject {
id: string;
tenantId: string;
permissions: ReadonlySet<Permission>;
approvalLimitCents: number;
}
export interface RequestContext {
mfaVerifiedAt: Date | null;
now: Date;
}
export interface Invoice {
id: string;
tenantId: string;
ownerId: string;
status: 'draft' | 'submitted' | 'approved';
amountCents: number;
}// policy/load-subject.ts
import { Pool } from 'pg';
import type { Permission, Subject } from './types';
export async function loadSubject(db: Pool, userId: string, tenantId: string): Promise<Subject> {
const { rows } = await db.query<{ permission_key: Permission }>(
`select distinct rp.permission_key
from memberships m
join role_permissions rp on rp.role_id = m.role_id
where m.user_id = $1 and m.tenant_id = $2`,
[userId, tenantId]
);
const limit = await db.query<{ approval_limit_cents: number }>(
`select coalesce(approval_limit_cents, 0) as approval_limit_cents
from tenant_users where user_id = $1 and tenant_id = $2`,
[userId, tenantId]
);
return {
id: userId,
tenantId,
permissions: new Set(rows.map((r) => r.permission_key)),
approvalLimitCents: limit.rows[0]?.approval_limit_cents ?? 0,
};
}Loading permissions per request (rather than baking them into a long-lived JWT) is a deliberate choice; I'll come back to why in the security section.
Step 4: Layer ABAC conditions on top of RBAC
Here's where the "only their own, only while draft, only under their limit" rules go. Not in the handler. In the policy, next to the permission they refine.
// policy/invoice.ts
import type { Invoice, RequestContext, Subject } from './types';
export type InvoiceAction = 'read' | 'update' | 'approve' | 'export';
type Rule = (s: Subject, inv: Invoice, ctx: RequestContext) => boolean;
const FIFTEEN_MINUTES = 15 * 60 * 1000;
const invoiceRules: Record<InvoiceAction, Rule> = {
read: (s) => s.permissions.has('invoice:read'),
update: (s, inv) =>
inv.status === 'draft' &&
(s.permissions.has('invoice:update_any') ||
(s.permissions.has('invoice:update') && inv.ownerId === s.id)),
approve: (s, inv, ctx) =>
s.permissions.has('invoice:approve') &&
inv.status === 'submitted' &&
inv.ownerId !== s.id && // no approving your own
inv.amountCents <= s.approvalLimitCents && // per-user limit
ctx.mfaVerifiedAt !== null &&
ctx.now.getTime() - ctx.mfaVerifiedAt.getTime() < FIFTEEN_MINUTES,
export: (s) => s.permissions.has('invoice:export'),
};
export function canInvoice(
s: Subject,
action: InvoiceAction,
inv: Invoice,
ctx: RequestContext
): boolean {
if (inv.tenantId !== s.tenantId) return false; // tenant boundary, always first
const rule = invoiceRules[action];
return rule ? rule(s, inv, ctx) : false; // deny by default
}
Notice what this buys you. Every rule about invoices is in one file. A reviewer can read approve and see all five conditions at once. When someone builds bulk-approve next quarter, they call canInvoice(s, 'approve', inv, ctx) for each invoice and get exactly the same answer as the single-approve endpoint.
The handler becomes boring, which is the goal:
app.post('/invoices/:id/approve', async (req, res) => {
const invoice = await invoices.findById(req.params.id);
if (!invoice || !canInvoice(req.subject, 'approve', invoice, req.authContext)) {
// 404, not 403, when the user shouldn't know the invoice exists at all
return res.status(404).json({ error: 'not_found' });
}
await invoices.approve(invoice.id, req.subject.id);
res.status(204).end();
});On 404 versus 403: if the user can't read the object at all, return 404 so you don't confirm that an id exists in another tenant. If they can read it but not perform this action, 403 is more honest and much easier to debug for your own frontend. I use canInvoice(s, 'read', ...) to decide which.
Step 5: Use relationships for shared objects instead of more roles
The moment your product has "share this document with a person or team," stop reaching for roles. A role describes what someone can do across a tenant. A share describes what someone can do to one object. Those are different things and they want different storage.

A small relationship model in Postgres covers a surprising amount of ground before you need a dedicated system:
create table folders (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
parent_id uuid references folders(id)
);
create table documents (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
folder_id uuid references folders(id)
);
create table team_members (
team_id uuid not null,
user_id uuid not null,
primary key (team_id, user_id)
);
create table grants (
resource_type text not null check (resource_type in ('folder', 'document')),
resource_id uuid not null,
relation text not null check (relation in ('viewer', 'editor', 'owner')),
subject_type text not null check (subject_type in ('user', 'team')),
subject_id uuid not null,
primary key (resource_type, resource_id, relation, subject_type, subject_id)
);
create index grants_subject_idx on grants (subject_type, subject_id);The check walks up the folder tree and looks for a grant on the document or any ancestor, either directly to the user or to a team they belong to:
create or replace function can_view_document(p_user uuid, p_doc uuid)
returns boolean language sql stable as $$
with recursive ancestry(resource_type, resource_id, parent_id, depth) as (
select 'document'::text, d.id, d.folder_id, 0
from documents d
where d.id = p_doc
union all
select 'folder'::text, f.id, f.parent_id, a.depth + 1
from folders f
join ancestry a on f.id = a.parent_id
where a.depth < 32 -- guard against accidental cycles
)
select exists (
select 1
from ancestry a
join grants g
on g.resource_type = a.resource_type
and g.resource_id = a.resource_id
where g.relation in ('viewer', 'editor', 'owner')
and (
(g.subject_type = 'user' and g.subject_id = p_user)
or (g.subject_type = 'team' and exists (
select 1 from team_members tm
where tm.team_id = g.subject_id and tm.user_id = p_user))
)
);
$$;Two things to notice. Relations are ordered (owner implies editor implies viewer), so a view check accepts all three. And the depth guard is there because the first time I shipped a recursive folder check, a bad migration created a folder that was its own grandparent, and the query never returned.
This in-database version is fine for thousands of shared objects and shallow hierarchies. When you need cross-service checks, very deep nesting, or "list everything Alice can see across ten object types," that's when a Zanzibar-style service such as OpenFGA or SpiceDB starts earning its operational cost.
Step 6: Enforce in the database with row-level security
Application checks are your primary enforcement. Row-level security is the net underneath them, for the day someone writes a new query and forgets. It's exactly the net that would have caught my invoice leak.
alter table invoices enable row level security;
create policy invoices_read on invoices
for select
using (
tenant_id in (
select m.tenant_id
from memberships m
join role_permissions rp on rp.role_id = m.role_id
where m.user_id = current_setting('app.user_id', true)::uuid
and rp.permission_key = 'invoice:read'
)
);The subquery doesn't reference the outer row, so Postgres can evaluate it once per query instead of once per row, which matters on large tables. Your application sets the user at the start of each transaction:
begin;
select set_config('app.user_id', '7c1d1d0e-5d0b-4a39-9a8e-2f3b6f0c9a11', true); -- true = local to this transaction
select id, amount_cents from invoices where status = 'submitted';
commit;If you're on Supabase, replace current_setting('app.user_id', true)::uuid with auth.uid() and the rest is identical. Two caveats I learned the hard way: the role your app connects as must not be a superuser or have bypassrls, or none of this applies; and RLS is a coarse filter. Keep the fine-grained ABAC rules (approval limits, MFA freshness) in the application, where they're readable and testable. Use RLS for the tenant boundary and the basic read permission.
Step 7: Filter lists with the same rules, not just single objects
A can() function answers yes or no for one object. A list endpoint needs the same rules expressed as a filter. Keep the two side by side in the policy module so they can't drift.

Here's what that looks like for invoices:
// policy/invoice.ts (continued)
export function invoiceReadFilter(s: Subject): { sql: string; params: unknown[] } {
if (!s.permissions.has('invoice:read')) {
return { sql: 'false', params: [] };
}
return { sql: 'tenant_id = $1', params: [s.tenantId] };
}
export function invoiceUpdatableFilter(s: Subject): { sql: string; params: unknown[] } {
if (s.permissions.has('invoice:update_any')) {
return { sql: `tenant_id = $1 and status = 'draft'`, params: [s.tenantId] };
}
if (s.permissions.has('invoice:update')) {
return {
sql: `tenant_id = $1 and status = 'draft' and owner_id = $2`,
params: [s.tenantId, s.id],
};
}
return { sql: 'false', params: [] };
}Whenever someone changes invoiceRules.update, the reviewer sees invoiceUpdatableFilter ten lines below it and asks whether it needs the same change. That proximity is a cheap but effective guard against the exact bug this post opened with.
Step 8: Log decisions and test the permission matrix
Two things make authorization maintainable once it exists: being able to explain a decision after the fact, and being able to prove the rules didn't change by accident.
For the first, log denials (and allows on sensitive actions like approve, export, and user:manage_roles) with enough context to reconstruct them:
logger.info('authz.decision', {
subjectId: s.id,
tenantId: s.tenantId,
action: 'invoice:approve',
resourceId: inv.id,
decision: allowed ? 'allow' : 'deny',
policyVersion: POLICY_VERSION,
});For the second, a table-driven check of roles against actions catches the most common regression: someone edits a role's permissions and silently grants something they didn't mean to.
import assert from 'node:assert/strict';
import { canInvoice } from './policy/invoice';
import type { Permission, Subject } from './policy/types';
const ctx = { mfaVerifiedAt: new Date(), now: new Date() };
const draft = { id: 'inv_1', tenantId: 't1', ownerId: 'u_owner', status: 'draft' as const, amountCents: 5000 };
const cases: { who: string; perms: Permission[]; action: 'update'; expect: boolean }[] = [
{ who: 'viewer', perms: ['invoice:read'], action: 'update', expect: false },
{ who: 'author', perms: ['invoice:read', 'invoice:update'], action: 'update', expect: false },
{ who: 'billing admin', perms: ['invoice:read', 'invoice:update_any'], action: 'update', expect: true },
];
for (const c of cases) {
const subject: Subject = { id: 'u_other', tenantId: 't1', permissions: new Set(c.perms), approvalLimitCents: 0 };
assert.equal(canInvoice(subject, c.action, draft, ctx), c.expect, `${c.who} ${c.action}`);
}
// cross-tenant must always be denied, regardless of permissions
const outsider: Subject = { id: 'u_x', tenantId: 't2', permissions: new Set<Permission>(['invoice:update_any']), approvalLimitCents: 0 };
assert.equal(canInvoice(outsider, 'update', draft, ctx), false);The cross-tenant case is the one I'd never skip. It's the cheapest possible test for the most expensive possible bug.
Performance and security considerations
Don't put permissions in long-lived tokens. It's tempting to put the full permission list into the JWT so you never hit the database. The problem is revocation: if you remove someone's invoice:export permission, they keep it until the token expires. Putting the tenant id and user id in the token is fine; resolve permissions per request, with a short-lived cache keyed by user and tenant if the lookup shows up in your profiles.
Cache, but invalidate on role changes. A per-request permission load is two indexed queries. If that's still too much, cache the permission set for a short window and bust the cache whenever memberships or role_permissions changes for that user. A short TTL is your safety net if an invalidation is missed.
Index for the questions you ask. memberships (user_id, tenant_id) is covered by the primary key above. grants (subject_type, subject_id) supports "what can this user see?" Check with EXPLAIN ANALYZE once you have realistic data, rather than guessing.
Never trust client-supplied tenant ids. The tenant should come from the session or from a membership lookup against the authenticated user, never from a header or body field the client controls without verification.
Separate "can see it" from "can see this field." Some data (salary, personal identifiers, internal notes) needs field-level rules. Handle it in the serializer with the same policy module, so a user who can read an employee record doesn't automatically see the compensation field.
Treat role management as the most privileged action in the system. Anyone who holds user:manage_roles can grant themselves everything else. Require recent MFA for it, log every change, and don't let someone grant a permission they don't hold themselves.
Common problems and errors
Users can see other tenants' data in list or search results
Cause: Single-object endpoints check the tenant, but a list query, search index, export job, or analytics query was written separately and doesn't.
Fix: Generate list filters from the same policy module as single-object checks (Step 7), and add row-level security on tenant-owned tables (Step 6) so a missed filter fails closed instead of open.
A user's permissions don't change after an admin updates their role
Cause: Permissions are embedded in a JWT or cached without invalidation, so the old set lives until the token or cache entry expires.
Fix: Resolve permissions per request from the database, or cache them briefly and invalidate on any change to that user's memberships or role grants.
New roles keep getting added and nobody understands the matrix anymore
Cause: Roles are being used to express conditions ("approver for EMEA under 10k") that belong in attributes, or one-off shares that belong in relationships.
Fix: Keep roles as coarse bundles of capabilities. Move conditions into ABAC rules that read attributes such as region and approval limit, and move per-object sharing into grants (Step 5).
Code checks role === 'admin' in dozens of places
Cause: Roles are checked directly instead of permissions, so every new role means auditing every check.
Fix: Replace each role check with a permission check through can(). Do it incrementally: add the permission, grant it to the roles that currently pass the check, swap the condition, and repeat. A grep for role === is a decent progress bar.
Recursive permission checks are slow or never return
Cause: Deep or cyclic folder hierarchies, or a recursive query without a depth guard, evaluated once per row in a list.
Fix: Add a depth limit, prevent cycles at write time, and for lists, compute the set of visible folder ids once per request rather than calling the recursive function per document. If the hierarchy gets genuinely large, that's the signal to evaluate a dedicated ReBAC service.
Best practices
- Name permissions as
resource:actionand check permissions in code, never role names. - Scope every role assignment to a tenant, and make crossing a tenant boundary structurally impossible.
- Route every decision through one policy module per resource, and deny by default.
- Add ABAC conditions next to the permission they refine, not inside individual handlers.
- Use relationship grants for per-object sharing instead of creating roles for it.
- Generate list filters from the same module as single-object checks, and back both with database row-level security.
- Log decisions on sensitive actions, and keep a table-driven test of roles against actions, including cross-tenant denial.
Trade-offs
None of this is free, and some of it is the wrong call for some products.
A central policy module is a dependency for everything. Every handler imports it, so a bug in it is a bug everywhere. That's also the point, but it means the module deserves the tests and code review you'd give to payment logic.
ABAC rules are harder to audit than a role matrix. "Who can approve invoice 42?" has no instant answer when the rule depends on amount, limit, and MFA freshness. If auditors or enterprise customers need to see access as a simple table, keep as much as possible in RBAC and keep ABAC rules few and well named.
Row-level security adds friction. Every connection needs the user context set correctly, migrations and background jobs need a deliberate bypass role, and debugging "why does this query return nothing?" gets harder. For a single-tenant internal tool, it may not be worth it.
Rolling your own ReBAC has a ceiling. The Postgres version above is great until you need cross-service checks, many object types, or fast "list everything I can access" queries. At that point a dedicated system is the better tool, and it brings its own consistency and operations questions.
Per-request permission loading costs a query. If you have extreme throughput requirements and permissions rarely change, putting them in short-lived tokens can be a reasonable trade. Just make the token lifetime short and decide in advance what happens when you need to revoke access immediately.
RBAC vs ABAC vs ReBAC vs a policy engine
| Approach | Best at | Weak at | Typical home |
| :---- | :---- | :---- | :---- |
| RBAC | Coarse capabilities per tenant, admin UIs, audits | Conditions on specific objects or context | Your own database tables |
| ABAC | Ownership, status, limits, time, region, MFA rules | Answering who can access something without evaluating every user | Policy code next to the app |
| ReBAC | Sharing, teams, folder and project inheritance | Global capabilities that have nothing to do with relationships | Grant tables, or OpenFGA or SpiceDB |
| Policy engine | Many services sharing one rule set, policy as code with its own review process | Small apps where the extra service is pure overhead | Open Policy Agent or Cedar |
The common pattern in products that have scaled well is not choosing one row of that table. It's RBAC as the backbone, ABAC for a handful of conditional rules, and ReBAC only for the objects users actually share.
When should you use each model?
Plain tenant-scoped RBAC is enough when
- Access is the same across all objects of a type ("billing admins manage all invoices").
- Your customers want to see and edit access as a list of roles.
- You don't have per-object sharing.
Add ABAC when
- Rules depend on ownership, status, amount, region, time, or authentication strength.
- You notice yourself creating roles that differ only by a condition.
Add ReBAC when
- Users share individual documents, folders, boards, or projects with people and teams.
- Access should be inherited through a hierarchy.
Consider a dedicated authorization service or policy engine when
- Several services must make consistent decisions about the same resources.
- Policies need to change independently of application deploys, with their own review.
- Your relationship graph has outgrown a recursive SQL query.
Questions I get asked about this a lot
What's the difference between RBAC and ABAC?
RBAC grants permissions through roles: if your role includes invoice:approve, you can approve. ABAC evaluates rules over attributes of the user, the resource, and the context: you can approve if you hold the permission, didn't create the invoice, and the amount is under your limit. Most real systems use RBAC for the capability and ABAC for the conditions.
Should I use a library like CASL, or a service like OPA or OpenFGA?
For a single application, a small hand-written policy module or a library like CASL is usually enough and easier to debug. Reach for Open Policy Agent or Cedar when multiple services need the same rules, and for OpenFGA or SpiceDB when sharing and inheritance become the core of your access model.
Should I put roles and permissions in the JWT?
Put identity and tenant in the token. Be careful with permissions: anything in a token can't be revoked until it expires. If you do embed them, keep tokens short-lived and have a plan for immediate revocation.
Is row-level security enough on its own?
No. RLS is excellent for the tenant boundary and basic read permissions, but complex conditions in SQL policies get hard to read, test, and keep fast. Use it as a safety net under application-level checks, not as the only layer.
How do I migrate from isAdmin checks to proper permissions?
Incrementally. Define permissions, create roles that reproduce today's behavior exactly, then replace checks one at a time with can() calls. Run the old and new checks side by side and log disagreements before you switch over completely.
Should a forbidden request return 403 or 404?
Return 404 when the user isn't allowed to know the resource exists, which includes anything in another tenant. Return 403 when they can see it but can't perform the specific action. That avoids confirming the existence of other tenants' ids while keeping errors debuggable for legitimate users.
A few things I got wrong before I got this right
- I checked tenants in single-object endpoints and assumed list queries would "obviously" do the same. They didn't, and a customer found it before we did.
- I made roles global. The first customer who invited an outside accountant to their workspace broke that assumption on day one.
- I put permissions in a 24-hour JWT to save a query. Then we had to remove access for someone immediately and had no way to do it short of rotating the signing key.
- I modeled document sharing as roles. We ended up with role names containing document ids, and an admin screen nobody could use.
- I shipped a recursive folder permission check without a depth limit. A bad data migration created a cycle, and the query spun until the connection timed out.
- I let
user:manage_rolesgrant any permission, including ones the granting user didn't hold. A support admin quietly gave themselves export access "to help a customer."
None of these were exotic. They're what happens when authorization is added one if at a time instead of designed once.
Conclusion
Authorization design fails slowly: a role string here, a copied condition there, a list query nobody checked. The fix isn't a specific acronym. It's tenant-scoped RBAC as the backbone, ABAC conditions for the rules that depend on the object or the moment, relationship grants for anything users share, and a single can() function that every handler, list filter, and job goes through, with row-level security underneath as the net. If you do one thing this week, find every list, search, and export query in your app and check that it applies the same tenant and permission rules as your single-object endpoints. That's where the leaks are.
If you're untangling a permission system that grew one if at a time, or designing multi-tenant authorization for a new product, our team has done this migration on real production systems and can help you plan it without breaking existing customers. Contact us to talk it through.
More from us
- Supabase in Production: Performance, Security, Backups, and Scaling
- Supabase Query Optimization: From Slow Query to Root Cause
- Designing Reliable Webhook Processing Systems
Further reading
- NIST: Role Based Access Control project
- NIST SP 800-162: Guide to Attribute Based Access Control (ABAC) Definition and Considerations
- Google Research: Zanzibar, Google's Consistent, Global Authorization System
- OWASP Authorization Cheat Sheet
- OWASP Top 10: A01 Broken Access Control
- PostgreSQL: Row Security Policies
- Supabase: Row Level Security
- Open Policy Agent documentation
- Cedar policy language
- OpenFGA documentation

Aarav Sharma
Lead Software Engineer
Aarav leads product engineering at Matlab Infotech, where he has shipped mobile and web platforms across healthcare, fintech, and SaaS. He writes about pragmatic engineering and shipping fast without cutting corners.
