Concepts
Permission checks in the request path
An authorization check that costs a network round trip is an authorization check somebody will eventually cache, skip, or move outside the loop. Making the check local — an array lookup against a verified session — is what keeps it in the one place it has to be: immediately before the thing it protects.
Check permissions, not roles
The first decision is what the check compares against. Code that asks whether the session role is admin works until the day a customer asks for a Finance role that can do everything an admin can except manage members — at which point every such comparison has to be found and widened, and the ones that are missed become a support ticket about somebody who cannot do their job.
Code that asks whether the session holds invoices:write does not have that problem. Adding a role becomes a configuration change: define it, give it the permission, assign it. No deploy, no search through the codebase, no risk of missing one.
The rule is worth stating plainly because it is easy to violate accidentally. A role name is a convenient thing to log, to display and to reason about in a design discussion. It is not the thing to branch on.
| Check | Survives a new role | Reads well in review |
|---|---|---|
| session.role === "admin" | No — every occurrence needs widening | Looks fine, which is the problem |
| can(session, "invoices:write") | Yes — configuration only | States what is being protected |
| session.groups.includes("Finance") | No — the customer owns that string | Hides a dependency on someone else's directory |
Where the check belongs
Authorization belongs next to the operation, not at the edge. A check in a route handler protects that route; the same operation reached through a background job, an internal service call or a bulk endpoint is unprotected, and those paths are added later by people who did not read the route handler.
The practical shape is a single function in your service layer that both takes the actor and performs the operation, so there is no way to call one without the other. Middleware still has a role — it establishes the session and rejects the unauthenticated — but it should not be the only thing standing between a request and a privileged action.
- 1Authenticate at the edge. Middleware verifies the session and attaches it; a request with no valid session never reaches your code.
- 2Authorize at the operation. The function that writes the invoice checks invoices:write, and takes the actor as a parameter so it cannot be called without one.
- 3Scope by tenant in the data layer. The permission says what the person may do; the tenant filter says whose records they may do it to. Both are required, and they are different checks.
- 4Fail closed. An unknown permission, an absent session or an unrecognised role grants nothing.
- 5Return 404 rather than 403 when the resource belongs to another tenant, so a probe cannot enumerate what exists.
What the check looks like
Permissions travel inside the session, so the check is a local lookup with no I/O. That is what makes it cheap enough to do everywhere rather than selectively, which in turn is what makes it reliable.
1export class Forbidden extends Error {}23// Oturum yetkileri tasidigi icin kontrol yerel — ag cagrisi yok.4export function can(session, permission: string) {5 return session.permissions.includes(permission);6}78export function assertCan(session, permission: string) {9 if (!can(session, permission)) {10 throw new Forbidden(permission);11 }12}1314// Islem ve kontrol ayni fonksiyonda — biri digersiz cagrilamaz.15export async function voidInvoice(session, invoiceId: string) {16 assertCan(session, "invoices:write");1718 return db.invoice.update({19 where: { id: invoiceId, organizationId: session.organizationId },20 data: { status: "void", voidedBy: session.userId },21 });22}
Permissions that change mid-session
A session carries the permissions it was issued with. When a role's permission set changes, or somebody is moved to a different role, existing sessions keep the old set until they refresh — which means the change takes effect within one access token lifetime rather than instantly.
For almost everything, that is fine and it is the trade that makes local checks possible. For the small number of operations where it is not — a permission change made in response to an incident, an approval limit, anything irreversible — check that one operation against the current membership rather than shortening every token in the system.
Being explicit about which operations those are is the useful exercise. Most teams find there are three or four, all of them obvious once written down, and none of them in the hot path.
The number to quote in a security review is the access token lifetime, because that is the maximum delay between a permission being removed and it stopping. It is a number you chose, not a property of the system.
Testing the negative case
Authorization tests that assert the allowed case pass whether or not the check exists. The tests that prove anything are the ones asserting refusal: a session without the permission is rejected, a session from another tenant gets a 404, an expired session is not accepted. Write one refusal test per protected operation and the suite starts describing the security model rather than the happy path.