Implementing Roles and Permissions Without Painting Yourself In

Written By
SprintX Team
AI & Product Engineering
August 13, 2026
7 min read

Most permission systems fail the first time a customer asks for something slightly unusual. Here is how to build one that bends instead of breaking.
Almost every permission system starts the same way: an if-statement checking whether the user's role equals "admin". It survives about four months.
What kills it is never a security researcher. It is a customer saying something reasonable — "our contractors should be able to upload files but not see the client list", or "this one person needs billing access without being an admin" — and you realizing the answer requires a new role, which requires touching thirty checks scattered across the codebase, each of which compares a string.
The way out is not a heavier framework. It is one modelling decision made early: check permissions, never roles.
Model actions, not job titles
Write down every meaningful thing a user can do in your product, phrased as verb plus object: invoice.create, invoice.void, member.invite, member.remove, project.delete, billing.view, export.run. Aim for granularity at the level of "an operation someone might reasonably be allowed to do without the neighbouring one."
Those strings are your permissions. A role is nothing but a named bundle of them. Admin is a set. Member is a smaller set. Billing contact is a strange little set that only contains billing.view and billing.update, and that is completely fine, because roles are cheap when they are just bundles.
Then the rule that makes the whole thing hold together: application code asks "can this user do invoice.void?" and never asks "is this user an admin?" Adding a role becomes a data change. Adding a permission touches exactly the code paths that permission governs.
The distinction sounds academic until you compare the two futures.
| Design | Adding a new role | Handling one-off exceptions | Where the logic lives |
|---|---|---|---|
| Role-string checks | Edit every check that lists roles | Add another role, then another | Scattered across handlers |
| Permission checks | Insert a row bundling permissions | Grant one extra permission | One authorization module |
Three tables, and one join
The storage model that covers 95% of products is small.
Permissions can live in code as a constant list — they change only when features change, and having them in version control means a typo fails a build rather than silently granting nothing. Roles live in a table, each with a set of permissions and a tenant it belongs to (plus a null tenant for system-defined roles like Owner). Assignments are the join: user, role, and the scope the role applies within.
That third column is the one people leave out and regret. A role assignment that is global to the account cannot express "editor on this project, viewer on that one", and retrofitting scope later means rewriting every assignment row and every check. Include the scope from day one, even if today every assignment is account-wide. It costs you one column now.
If you are also adding organizations at the same time, do the tenancy work first — permissions are scoped inside a tenant, so the retrofit order matters. Adding multi-tenancy to a single-tenant app covers that migration.
Enforce in one place, on the server
The check itself should be a single function used everywhere, something along the lines of authorize(user, 'invoice.void', invoice). It resolves the user's assignments for the relevant scope, expands roles into permissions, and returns a decision. One function means one place to add logging, one place to fix a bug, one place to reason about.
Where it gets called matters more than how it is written:
- Every server entry point, without exception. Route handlers, RPC methods, GraphQL resolvers, webhook processors, scheduled jobs. Anything that acts on behalf of a user checks first.
- Not the client, for enforcement. Hiding a button is good UX and zero security. The endpoint behind it is still reachable with a terminal, and the most common real-world authorization bug in AI-assisted codebases is exactly this: a hidden UI over an unguarded route. Auth bypasses in AI-generated apps is a catalogue of how that plays out.
- At the data layer too, if you can. On Postgres, row-level security gives you a second net that catches the endpoint someone forgot. Supabase roles and row-level security walks through the pattern.
One deliberate exception to "check everywhere": send the user's effective permission list to the client once, on login, purely so the UI can render correctly. It is a hint, not a gate, and it should be derived from the same source as the server-side check so the two never disagree.
Leave room for the things RBAC cannot express
Pure role-based access control assumes permission depends only on who you are. Real products keep producing cases where it depends on the object.
The document that only its author can delete. The project shared with one external reviewer. The record locked after approval so even an admin cannot edit it. The customer whose contractors should see one folder and nothing else.
You do not need a policy engine to handle these, and reaching for one on day one is how small teams end up with an authorization system nobody understands. What you need is for your authorize function to take the resource as an argument from the beginning — even while it ignores it. That signature is the escape hatch. When the first object-level rule arrives, you add it inside the function, and no call site changes.
Two rules keep this from sprawling. Deny wins over allow: if any rule says no, the answer is no, regardless of how many roles say yes. And there are exactly two answers, allowed or denied — the moment you introduce partial states, nobody can predict behavior anymore.
Make it visible and testable
Two things turn a permission system from a liability into an asset.
First, a table test. One test file, a matrix of role against action against expected result, run for real through the actual authorization function. When someone changes a role definition, the test tells them exactly which of the forty-two combinations changed. This is the highest value-per-line testing in an application, and it is the thing that lets you refactor permissions later without fear. If your codebase has no tests at all yet, this is the place to start — see adding tests to AI-generated code.
Second, log every denial and every permission change. Denials tell you where users are hitting walls you did not intend — often a legitimate workflow you modelled wrong rather than an attack. Changes to who has what access are the first thing any enterprise customer or auditor asks for, and reconstructing them later from database backups is miserable. Building an audit trail covers the format and retention, and it is the same evidence enterprise buyers ask for during security reviews.
Frequently asked questions
Do I need a dedicated authorization service? Almost certainly not at your stage. A permissions table, a role-to-permission mapping, and one authorize function will carry a product a long way. Dedicated policy engines earn their complexity when you have deeply nested resource hierarchies or cross-organization sharing — problems you will recognize clearly when you have them.
How many roles should I ship with? Three: Owner, Admin, Member. Owner is a single non-removable account holder, Admin manages members and settings, Member does the work. Add roles when a customer describes an actual job that none of the three fit — not preemptively, because every unused role is a matrix cell you have to keep testing.
Where should permission checks live in the request lifecycle? After authentication and after you have loaded the resource, before any side effect. Loading first matters: to decide whether someone can edit an invoice you need to know which tenant it belongs to and what state it is in. Checking on the way in with only an ID is how cross-tenant access slips through.
If your access control is currently a scatter of role-string comparisons and the next customer request is going to break it, that is a two-week fix now and a rewrite later. SprintX designs permission models that survive real customer demands, enforces them server-side and at the database, and hands over the test matrix that keeps them honest. Walk us through your current setup and we will show you where it leaks.


