← All writing

/ 4 min read

Hide the button, enforce on the server: authorization in multi tenant products

Why role checks scattered through a codebase stop scaling, how relationship based access control fixes it, and how the frontend should ask for permissions without becoming the place that enforces them.

  • Security
  • Architecture
  • API design

Authorization starts simple in every product. There are admins and there are users, and an if (user.role === "admin") in a few places covers it. Then the product gets a second customer, then teams inside each customer, then a request that someone may approve payments in one branch but only view them in another. A year later, the rules live in dozens of places and nobody can say with confidence who can do what.

I have helped design the authorization layer behind a multi tenant enterprise platform, and built the screens on top of it. These are the principles I would start with again.

Two different jobs

Every permission has two jobs, and mixing them up causes most of the bugs. The first job is enforcement: refusing an action that is not allowed. The second is affordance: not showing someone a button they cannot use. Enforcement must happen on the server, on every request, without exception. Affordance is a courtesy the frontend provides on top.

A disabled button protects nothing. Anyone can open the browser console and call the API directly, so every check the UI performs has to be repeated by the service that owns the data. The UI check exists so people are not invited to try things that will fail.

Roles stop scaling before you expect

Role based access control works while permissions depend only on who you are. It breaks when they depend on what you are related to: this document because you created it, this branch because you manage it, this project because your team was added to it. Teams try to express that with more roles, branch_42_approver and friends, and the role list grows with the data.

Relationship based access control, the model described in Google’s Zanzibar paper and implemented by systems such as SpiceDB and OpenFGA, starts from the other side. You store relationships between subjects and objects, and you define permissions as expressions over those relationships.

definition user {}

definition tenant {
relation admin: user
relation member: user
}

definition branch {
relation tenant: tenant
relation manager: user
relation viewer: user

permission view = viewer + manager + tenant->admin
permission approve = manager + tenant->admin
}

definition account {
relation branch: branch
permission view = branch->view
permission approve_payment = branch->approve
}
A small schema: tenants own branches, branches hold accounts, and permissions follow the relationships.

The answer to "may Sara approve this payment?" is now a single question to one service: check(account:1234, approve_payment, user:sara). Making someone a branch manager is writing one relationship, and every permission that follows from it updates at once. No code changes, no new roles.

Tenancy is a relationship, not a filter

In a multi tenant product the worst bug is not a wrong permission inside a tenant. It is data from one customer appearing for another. I treat tenant isolation as a separate, boring layer that does not depend on the clever one: every table carries a tenant id, every query is scoped by it through one shared data access function, and the tenant comes from the verified session, never from a request parameter.

The permission system then expresses tenancy as relationships too, as in the schema above, so an administrator of one tenant has no path to another tenant’s branches. Two independent layers mean one mistake does not become a breach.

One guard on the server

On the service side, I want enforcement to be impossible to forget. Each operation declares the permission it needs, and one guard performs the check before the handler runs.

export async function approvePayment(session: Session, accountId: string, input: PaymentInput) {
await requirePermission(session, { type: 'account', id: accountId }, 'approve_payment')
return payments.approve(session.tenantId, accountId, input)
}

async function requirePermission(session: Session, resource: Resource, permission: string) {
const allowed = await authz.check({ subject: { type: 'user', id: session.userId }, resource, permission })
if (!allowed) throw new ForbiddenError(permission, resource)
}

Code review then has one easy rule: an operation that touches data without requirePermission on its first line does not merge.

Asking for permissions from the frontend

A list screen may show fifty rows with four actions each. Checking two hundred permissions one request at a time is slow and noisy. Instead the API offers a bulk endpoint: the client sends the resources on screen and the actions it wants to render, and gets back a map of what is allowed.

const { data: can } = usePermissions(
rows.map((row) => ({ type: 'account', id: row.id })),
['view', 'approve_payment'],
)

<Button disabled={!can?.[row.id]?.approve_payment} onClick={() => approve(row.id)}>
Approve
</Button>

Two rules keep this honest. The client treats a missing answer as no, so a slow or failed permission request never shows an action by accident. And the answer is only ever used for rendering, while the server checks again when the action is taken.

Test the matrix, not the code

Authorization bugs hide in combinations, so I test them as a table: for each persona, each resource and each action, the expected answer. The table is readable by product owners, which turns "who can do what" from tribal knowledge into a document people review. When the schema changes, the table shows exactly which answers changed with it.

  • Keep enforcement on the server, on every request, behind one guard.
  • Model permissions as relationships once roles start depending on data.
  • Isolate tenants in a separate, simple layer that does not trust the request.
  • Let the frontend ask in bulk, render from the answer and treat silence as no.
  • Test the permission matrix as data that people other than engineers can read.

Next article ↗

The query that trusted its input: finding SQL injection in a no code platform