Authorization: roles, grants, plans and API tokens
How a multi-tenant product decides what each caller may do: RBAC, ABAC, ReBAC and per-record grants, the order a policy is evaluated in, tenant checks, why some refusals are 404, plans and limits as data, quotas charged without races, and API tokens and service accounts behind deny-by-default routes - with pictures and examples.
In short
Authentication answers who is calling; authorization answers what they may do, on every single request. In {{brand}} the server decides in a fixed order: is the caller really in the tenant they name, may this kind of caller use this route, does the plan include the feature, do their role and grants open this record, and is there quota left. The web app hides buttons you cannot use, but that is only a convenience - the server checks again, every time.
After this lesson you can Compare RBAC, ABAC and ReBAC and pick one for a product; build a tenant check that never trusts the client and choose between 404 and 403; model plans and quotas as data and charge them without race conditions; design API tokens and service accounts with scopes, expiry, rotation and deny-by-default routes.
Words you need
- Authentication vs authorization Authentication proves identity - a password, a passkey, single sign-on. Authorization decides what that identity may do. Different questions, different code.
- Principal Whoever is calling: a person with a browser session, or a machine with a token. Every decision is about one principal.
- Permission and role (RBAC) A permission is one right, such as manage boards; code asks for permissions, never for role names. A role is a named bundle of permissions - Admin, Manager, Member, Viewer. Role-based access control gives rights through roles.
- Tenant One customer's walled-off space - in {{brand}} an organization, or a person's own workspace. Data never crosses from one tenant to another.
- Attribute (ABAC) A fact about the caller, the record or the moment - department, status, time of day. Attribute-based access control writes rules over facts.
- Relationship (ReBAC) A link such as Aynur is in team Backend, and Backend may edit board Platform. Relationship-based access control follows those links.
- Grant A right on one record, given to a person, a team or a role - for example View, Comment, Edit or Manage on one board.
- Entitlement What a plan gives a workspace: a feature, or an amount of something. Kept as data, not code.
- Quota How much may be used in a period or held at once - an AI budget per month, boards per workspace. Past it, the answer is 429.
- Scope What a token may reach - for example one module, read or write. A scope only ever narrows.
- Service account A non-human identity that belongs to the organization, for pipelines and integrations. No password, no seat, no inbox.
- BOLA / IDOR Broken object-level authorization (also called insecure direct object reference): the server finds a record by id and forgets to check that the caller may touch it. The most common API flaw.
Four ways to model authorization
Every authorization system answers one question: may this principal do this action on this resource? The models differ in where the rule lives. Here is the same question - may Aynur edit the board Platform? - in each of them.
Real products mix them Pure RBAC is too coarse and pure ACLs are too many to manage. Most products combine a role that sets the most a person can ever do with grants or relationships that open single records. {{brand}} does exactly that - the next section shows the rule.
Roles and grants together: the ceiling rule
{{brand}} uses two layers. The role a person holds in the organization is the ceiling: the most they may ever hold on any record of a module. Inside that ceiling the record decides: how it is shared - open to everyone, to a team, or private - and which grants name the person, a team of theirs or a role of theirs. Why a ceiling at all? Sharing is done by many people, and mistakes happen: someone grants Manage to a Viewer. With a ceiling, the role is the one place that caps the damage, so no share, however generous, can lift a person above what their organization role allows.
- 1. Find the ceiling From the person's role. The ceiling has two numbers: the most the sharing can open to them, and the most a grant can give them. On boards, a Viewer looks, a Member changes cards, a Manager manages. A role that does not reach the module means nothing at all.
- 2. Ask the record's sharing Open to everyone? Filed under the person's team? Private? The sharing preset gives a level, or none.
- 3. Ask the grants Grants that name the person, one of their teams or one of their roles give the best level among them.
- 4. Combine Cut the sharing level to the first number and the grant level to the second, then take the better of the two. Neither can lift someone above the ceiling for that kind of access.
- 5. Answer Enough level: allowed. Can see it but not enough: refused with a reason. Cannot see it: not found.
Three rules that keep it safe No role opens everything: a private record stays closed even to an administrator until they open it on purpose, and that act is recorded on the record. Teams never make managers: a team's sharing lets its members work on a record, but managing it takes a grant. Write the order down: compare levels through one table, so adding a level later cannot silently shift every answer.
The same rule is needed in two forms. A check on one record runs it in memory. A list must page in the database, so the rule is also written as a query filter. Two forms of one rule can drift apart, so a test writes thousands of combinations of sharing, teams and grants and demands that both forms give the same answer.
Policy evaluation order: which question comes first
Order matters for speed, for safety and for what a refusal reveals. Cheap, broad questions go first; questions about one record go last, once the caller is known to belong. At every step the rule is deny by default: if a step cannot say yes, the answer is no.
- Who is calling The session or the token is verified. A credential that fails is refused at once, never swapped for another sign-in.
- May this kind of caller use this route A token must hold the scope this route needs, and some routes stay a person's own act.
- Which tenant The organization the request names is checked against the database: a real, active membership. Otherwise: not found.
- Is the feature available It exists, is switched on, and its audience includes this caller.
- Does the plan include it The workspace's plan lists the feature.
- Did the customer switch it off The customer can switch a capability off for everyone or for part of its people; the most specific rule wins.
- The organization's own rules Policies the customer chose, such as stricter sign-in or a read-only state.
- Inside the use case: the record The role ceiling and the record's grants decide: not found, too low a level, or allowed.
- Inside the use case: the quota Is there room under the plan's limit? If not: 429, with how much is used and when it resets.
- The write Only now is anything saved.
Decide in one place, enforce in many Security people call the place that decides the policy decision point and every place that asks it a policy enforcement point. {{brand}} has one decision point per kind of question - one evaluator for features, plans and switches, one calculator for records - and every route, list and background job asks them. No handler writes the rule a second time.
Tenant checks: the server decides which organization you are in
In a multi-tenant product every customer shares the same servers and the same database tables. The one thing that must never happen is a person seeing another customer's rows. The defence is simple to say: the tenant always comes from a server-side check, never from what the client claims.
- The client names a workspace The web app sends the organization the person is working in with every request. This is a claim, not a fact.
- The server verifies it It looks up an accepted, active membership in the database. Not a member: not found. A member whose access is paused: told so.
- A context is built The caller, the verified organization and their standing go into one per-request access context.
- Queries are filtered Database query filters read that context, so every query is cut to the tenant - even one a developer forgot to filter by hand.
- Ids in the body change nothing An organization id inside a request body, or a record reached by its id, never changes which tenant is used. Only the verified context counts.
- Other entry points check too Realtime connections and background jobs enter by another door, so each verifies the person's own rights where it acts.
Soft delete (DeletedAt) rides in the same filter, so a deleted row disappears from every query the same way another tenant's rows do.
Someone in two organizations A person who belongs to two organizations sees only the one they are working in. Everything that reads data - lists, search, reports - must take the tenant from the verified context, never from a parameter. One forgotten code path leaks.
Staff access is not a back door Platform staff accounts must not be a back door. Inside a customer's tenant, staff are treated like anyone else - a stranger unless they are a member - and tests call every route that way. Decisions about someone else - who is notified, who may see a mention - always use that person's own rights, never the caller's.
404 or 403: what a refusal may reveal
A refusal is information. If one board id answers 403 Forbidden and another answers 404 Not Found, an attacker has learned that the first board exists. Trying ids one after another to learn this is called enumeration. The fix is to answer a closed record exactly like a missing one.
- Can the caller see the record at all? No - or it does not exist, or it lives in another tenant: answer 404, with the same body as for an id that was never made.
- Can they see it, but need more? Answer 403 with the reason, for example you can view this board but not change its cards. They already know it exists.
- Is it a list? Never refuse a list; simply leave out what the caller may not see.
- Is a child record involved? Resolve a card, a comment or a run to its parent first and decide on the parent, so a child never leaks a closed parent.
Leaks hide in other places too Different response times, different error texts, totals in a list header, or a uniqueness error such as that name is already taken can each reveal what a 404 hid. Keep the body, the timing and the side effects the same.
The options in the market
- Framework roles only Role checks in attributes or middleware, such as ASP.NET Core's role-based policies; Built in, nothing extra to run; Easy to read; Cannot open one record to one person; Role names spread through the code
- Policy engine: OPA, Cedar Rules in a policy language, evaluated by an engine beside the app; One language for many services; Policies reviewed and tested apart from the code; The facts must be sent to the engine for every decision; Lists and paging are hard: the engine cannot write your SQL filter
- Relationship service: OpenFGA, SpiceDB Zanzibar-style: store relationship tuples and ask may user X edit doc Y; Scales to huge, deeply nested sharing graphs; Can answer what can X see; Another service to run, secure and keep in step; Every share is written twice: your database and the tuple store
- Hosted authorization: Okta FGA (formerly Auth0 FGA), Permit.io, Oso Cloud A managed service with an API and admin screens; No engine to operate; Policy screens included; The shape of your customers' data leaves your system; Latency and cost on every check
- In-process hybrid: role ceiling plus record grants One calculator inside the app, also written as a database filter; No network hop; rights read live from the same database; Lists page in SQL with the same rule; Plain code, unit-testable; The rule exists in two forms and needs a proof test; No policy language for people who do not write code
What {{brand}} chose, and why
{{brand}} is a modular monolith with one PostgreSQL database, so every fact a decision needs - memberships, roles, teams, grants - sits beside the records. An in-process calculator reads those facts live on each request, and the same rule becomes a query filter for lists. There is no second store to keep in step, so a removed grant counts from the very next request.
- Two forms of one rule: the calculator and the filter must agree, so a proof test checks thousands of combinations - in memory and on a real PostgreSQL.
- No policy language: the rules are code, reviewed like code. Roles, plans and limits are data that admins change without a deploy.
- One evaluator, one order: every feature, plan and switch question goes through one service, so no handler can skip a step.
- Many services in different languages that must share rules: a policy engine such as OPA or Cedar.
- Sharing graphs with millions of nested folders and groups: a Zanzibar-style service such as OpenFGA or SpiceDB.
- An internal tool with three roles and nothing shared one by one: framework roles are enough.
- Quotas in the database, not Redis: one source of truth that billing can trust; the trade-off is a transaction and a lock to maintain.
- Opaque tokens, not JWT: the server looks the token up, so revoking works at once; the trade-off is a lookup per request. Choose JWT instead when many services must verify tokens without calling one database.
- Deny by default plus an approval test: a forgotten route fails the build, not production; the trade-off is that a reviewer must read the route diff.
Plans and limits as data
A plan is a row, not an if. Code never asks whether the caller is on Pro; it asks whether the workspace's plan includes a feature key, and what the limit of a metric is. Prices and limits then change in an admin screen, with no deploy.
- The workspace's plan An organization's own plan, or the person's own in their personal workspace. A cap belongs to the workspace; a metered limit counts per person, or is pooled across the organization's paying members, depending on the limit.
- Temporary raises A promotion or a grace period for everyone on the plan.
- The workspace's own override Support or a contract raises one workspace.
- The most generous wins No limit beats any number, and raises only add - except where a trial or a license sets an upper bound. When a raise ends the plan's value returns - nothing is deleted.
Caps are counted where the rows live. Each module reports how many of a thing a workspace holds through a small meter interface, and the limits service adds every meter up. Storage lives in several modules but is one limit - and the limits code never depends on any module. This is dependency inversion applied to billing.
Downgrades never delete When a plan stops including something, what the workspace holds stays readable, exportable and deletable; only adding and changing wait. Losing customer data over billing is never acceptable.
Quotas and metering: charge atomically
The classic bug: two requests arrive together, both read 9 of 10 used, both decide there is room, both write. The workspace now holds 11. This is a race condition, and any check followed by a separate write has one. The check and the charge must be one atomic step.
- Request A reads Used: 9, limit: 10 - room for one more.
- Request B reads Also 9 - A has not written yet.
- Both write Each sets the counter to 10 although two units were spent, so the real use is 11 against a limit of 10 (or two rows are inserted).
- The fix Make read, check and write one unit: a conditional update, a serializable transaction, or a lock held until the write lands.
- Read, then write Check in code, save later; The simplest code; Races: two requests take the last unit
- Conditional UPDATE Check inside the statement; One statement, fast; No retries; One counter at a time; several counters at once need care
- Serializable transaction {{brand}}'s choice for counters: several counters are checked and charged together, or none are.; All counters charged or none; The database itself detects the race; Must retry on a serialization failure; A retry must read fresh rows again
- A Redis counter: INCR with EXPIRE Counters in an in-memory store; Very fast; Expiry per window is built in; A second store to keep in step with billing; Can lose counts on a failover
- Read, then write Count in code, insert later; The simplest code; Races: two requests take the last slot
- A unique constraint Let the database refuse a duplicate; No lock to manage; Fits "one per" rules, not "at most ten"
- SELECT FOR UPDATE on a counter row Lock one row that holds the count; Familiar and simple; A counter that can drift from the real rows; Deleting must remember to decrement
- A lock per workspace, then count {{brand}}'s choice for caps such as projects per workspace: counts the real rows, so deleting frees room.; Counts the real rows, so deleting frees room; Works across modules, and across servers when the lock lives in the database; Writes in one workspace wait for each other; A lock needs a timeout
Some work only knows its cost afterwards: an AI answer is priced by the tokens it produced. The safe pattern is to reserve the most it can cost before the call, refuse when the budget cannot cover that, then settle to the real cost and release what was over. A failed call gives everything back, so calls made at the same moment can never spend past the budget together.
API tokens and service accounts
A program cannot type a password or click a sign-in page, so it uses an API token: a long random secret sent in the HTTP Authorization header. A token is a key that works without its owner present, so it must be narrow, short-lived, revocable and visible in an audit trail.
- Made on purpose Creating a token is a deliberate, re-authenticated act, and the owner is told that one was made.
- Shown once The secret appears one time and can never be read again. Lose it, make a new one.
- Checked live Prefer a design where revoking, expiring or removing the owner stops the token at once, not when a cache runs out.
- Bounded It works in one workspace, within its scopes, and never above what its maker may do at that moment.
- Ends Every token expires, reminders go out before it does, and revoking works at once.
- Rotated Make the new token, deploy it, watch the old one's last use stop, then revoke the old one.
- Signed JWT Self-contained: the server checks a signature, with no lookup; No database read per request; Works across many services; Cannot be revoked before it expires without a deny list; The rights inside it go stale
- Opaque token, checked on every request A random string the server looks up; Revoke, suspend or remove a member and it stops on the next request; Rights are read live, never stale; A lookup per request; Needs protection against guessing and floods
- Short-lived OAuth access tokens (client credentials) An identity provider issues tokens that last minutes; Limits damage from a leak; Standard, works across many services; A token endpoint and a provider to run; Client secrets still need care
- Signed requests (HMAC, as in AWS SigV4) Each request is signed with a secret that never travels; The secret is never sent; Replay can be limited by timestamp; Harder for clients to implement; Hard to try with plain curl
- Scopes per area and level: give a pipeline only what its job needs.
- Never more than its maker: a good design lets a machine identity hold no right its creator lacks.
- Some acts should stay human: sharing, inviting and billing are usually kept out of tokens' reach.
- Classify by effect, not by HTTP verb: a read with side effects or sensitive output deserves the write right.
- Quotas follow the organization, not the account, so extra accounts never multiply a limit.
Audit and change: who sees an access change
Access is only trustworthy if changes to it leave a trace. Every change to a role, a grant or a token should be written to an audit trail that says who did what and when - and the person affected should usually be told.
Events carry the news Notifications about access are best driven by events: the code that changes a grant announces a fact, and the notification code listens. Narrowing, ending and removal then notify the same way from every screen.
Deny by default: every route must say who may call it
The most common authorization bug is not a wrong rule but a missing one: a new endpoint nobody thought about. The cure is to make the safe answer the default - platform routes refuse tokens unless they opt in, module routes take a safe default by method, and an approved route list fails the build until someone reviews any new or changed route.
- Give every route an explicit rule Say who may call it and with what right. A route nobody decided about should refuse, not allow.
- Choose a safe default For the unlabelled, a common convention is that reads need a read right and everything else a write right.
- Keep a reviewed list Keep a list of all routes and their rules in version control, one line per route.
- Test the running app against the list A test compares the running app with the list, so a new or changed route fails the build until a person reviews it - the diff is the review.
Approval testing Comparing a generated list with a checked-in approved file is called approval or snapshot testing. It turns a silent change - a new route, a new permission - into a visible line in a pull request that a reviewer must read.
Try it yourself: a tiny authorization layer
In an afternoon, with Node.js or .NET and SQLite or PostgreSQL, you can build - and break - the main ideas of this lesson. Expect the race test to print 1 and then 7 once the fix is in.
- Set up Run npm init -y and install express and better-sqlite3, or start a .NET minimal API with SQLite. One file, one database.
- Model the data Tables for organizations, memberships (person, organization, role), boards (organization, sharing) and grants (board, person or team, level).
- Write the ceiling rule Implement effective(open, granted, sharing, grants) and unit-test the corners: a grant above the role, a private board, a team's board.
- Answer 404 or 403 A helper that throws not found when the caller cannot even view, and forbidden when the level is too low.
- Break a quota Allow one board, fire 8 create requests at once and count the successes. Then fix it with a conditional update or a lock and see exactly one.
- Add a token Generate 32 random bytes, keep only a one-way fingerprint of the token, look it up on every request and check its scopes.
Common mistakes
- Trusting the client's tenant id: an organization id from a header, a body or a URL is a claim. Verify the membership on the server, on every request.
- 403 for records the caller cannot see: it confirms they exist. Answer 404 with the same body as a missing record.
- Check-then-write quotas: a separate read and write lets two requests take the last unit. Charge in one atomic step and prove it with a race test.
- Checking the route but not the record: GET /boards/42 with a valid login must still ask whether this caller may see board 42.
- Tokens that never expire or reach everything: give each one scopes, one workspace, an expiry and a last-used date.
- Switching off every filter by accident: in EF Core, IgnoreQueryFilters() inside a subquery turns off the tenant filter of the whole query. Run the unfiltered lookup as a query of its own.
Interview questions
- RBAC, ABAC or ReBAC - how do you choose? RBAC when rights follow job roles and records are not shared one by one. ABAC when rules depend on facts such as department, status or time. ReBAC when sharing, teams and nesting decide access - and most products use roles as a ceiling with per-record grants inside it.
- How do you make sure one tenant never sees another's data? The tenant comes from a server-side membership check, never from the client. A per-request context feeds database query filters, so every query is cut to the tenant, and tests call routes as a member of another tenant.
- When do you answer 404 and when 403? 404 when the caller may not see the resource at all, with the same body as a missing one, so nothing can be enumerated. 403 only when they can see it but lack the level, because the reason then tells them nothing new.
- In what order do you evaluate a policy? Identity, then whether this kind of caller may use the route, then the tenant, feature, plan and switches, then the record, then the quota, then the write. Broad, cheap checks first, and deny by default at every step.
- Two requests race for the last unit of a quota. How do you stop both succeeding? Make the check and the charge one atomic step: a conditional UPDATE, a serializable transaction with retry, or a lock held until the write lands. Prove it with a test that fires many requests at once and expects exactly one success.
- How would you design API tokens and service accounts? Each token is shown once, scoped to what it needs, bound to one workspace, expiring, revocable and checked live, stored in a form that cannot be reversed. A service account has no password or seat and never does more than the person who made its token may do at that moment.
- How do you stop a new endpoint from being exposed by mistake? Deny by default: platform routes refuse tokens unless they opt in, module routes take a safe default by method, and an approval test lists every route with what it asks. A new or changed route fails the build until someone reviews it.
- What is BOLA/IDOR and how do you prevent it? The server returns or changes a record by id without checking the caller's right to that record. Resolve every id through an access check that includes the tenant, answer 404 for what is closed, and test with a second user's ids.
- JWT or opaque token for an API? JWT avoids a lookup but cannot be revoked before it expires and its rights go stale; an opaque token costs a lookup but revoking, suspending and role changes apply on the next request.
- Is hiding a button enough? No. The UI is a convenience; the server decides on every request, because anyone can call the API directly.
Related
- /help/technology/sign-in
- /help/technology/request-path
- /help/technology/payments