Advanced Access Control & Authorization
Authentication answers who the caller is; authorization answers what they may touch, and it is where the expensive breaches happen — a mis-scoped query returning another tenant’s invoices does not look like an attack in any log until someone reads the data. This guide covers the models, the placement of enforcement, and the operational machinery needed to make authorization decisions that are fast, consistent across services, and provable after the fact.
Architecture Overview
Every serious authorization design separates two responsibilities: deciding (the policy decision point) and enforcing (the policy enforcement point). The decision point owns the rules and the data they need; the enforcement point sits in the request path, asks the question, and refuses to continue on a deny. Keeping them apart is what lets you change a rule without redeploying twelve services — and what stops each service inventing its own subtly different interpretation of “owner”.
How Authorization Models Evolve
Nearly every system starts with a role column on the users table and a handful of if (user.role === "admin") checks. That works until the first customer asks for something the roles cannot express — “editors can publish, but only in their own region, and only before the freeze date” — and the team faces the usual fork: add another role for every combination, or start evaluating attributes.
Adding roles is seductive because it is a small change each time. It ends in a role explosion: dozens of near-identical roles whose differences nobody remembers, which is both an audit problem and a security problem, because over-broad roles get handed out to unblock people. Foundational implementations start with designing role-based access control systems, and the migration decision is covered in choosing between RBAC and ABAC.
The healthier pattern is layered: keep roles for coarse grants that map to job functions, and express the exceptions as attribute conditions evaluated at request time. Roles stay auditable and cacheable; attributes carry the context — resource owner, tenant, time, device posture, data classification — that roles cannot encode without multiplying.
Choosing an Authorization Model
| Model | Evaluation basis | Ideal use case | Strength | Latency profile |
|---|---|---|---|---|
| RBAC | Static role-to-permission mapping | Internal admin panels, predictable role sets | Simple to audit and cache | Under 10ms with a local cache |
| ABAC | Subject, resource, action and environment attributes | Multi-tenant SaaS, compliance-driven data access | Context-aware, fine-grained | 15–40ms including policy evaluation |
| ReBAC | Graph relationships, Zanzibar-style tuples | Document sharing, nested groups, collaboration | Scales to deep object hierarchies | 20–50ms including graph traversal |
| Policy engine | Declarative logic plus external data | Zero-trust networks, dynamic risk scoring | Decoupled, versioned policy as code | 30–60ms with a remote decision point |
Collaborative products that must answer “who can see this specific document, including through three levels of group nesting” reach for relationship-based access control with OpenFGA, where permissions are edges in a graph rather than rows in a role table. Systems whose rules change faster than their deployments reach for Open Policy Agent so the rules ship as versioned, testable bundles.
Whatever the model, authorization data must be bound to the issuing authority and validated — a role claim in a JWT is only as trustworthy as the signature check in front of it, which is why RFC 8725 insists on an explicit algorithm allowlist and why claims consumed for authorization should be mapped through your own allowlist rather than trusted verbatim.
Where to Put the Enforcement Point
The same rule enforced in three different places gives three different failure modes, and the choice is an architecture decision rather than a preference.
The gateway is where you stop unauthenticated and obviously unauthorised traffic cheaply, and where you apply blanket rules like “no requests to the admin API from outside the corporate range”. It cannot make object-level decisions, because at that layer the request is a URL and a token, not a record with an owner. That distinction is exactly the gap broken-object-level-authorization findings live in, and the reason middleware patterns for permission validation put a second check inside the service. Where those checks belong in a distributed deployment is covered in policy enforcement points in microservices.
import type { Request, Response, NextFunction } from "express";
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(new URL(process.env.JWKS_URI!));
const OPA_URL = process.env.OPA_URL ?? "http://localhost:8181";
export async function authorize(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace(/^Bearer /, "");
if (!token) return res.status(401).json({ error: "missing credential" });
let claims;
try {
({ payload: claims } = await jwtVerify(token, JWKS, {
issuer: process.env.ISSUER!,
audience: process.env.AUDIENCE!,
algorithms: ["RS256", "ES256"], // explicit allowlist — never read from the token
}));
} catch {
return res.status(401).json({ error: "invalid credential" });
}
const input = {
action: req.method,
path: req.path,
subject: { id: claims.sub, roles: claims.roles ?? [], tenant: claims.tenant_id },
resource: { id: req.params.id, type: req.path.split("/")[1] },
};
const decision = await fetch(`${OPA_URL}/v1/data/authz/allow`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ input }),
signal: AbortSignal.timeout(150), // fail closed rather than hang the request
}).then((r) => r.json()).catch(() => ({ result: false }));
if (decision.result !== true) return res.status(403).json({ error: "forbidden" });
next();
}
Note the two defensive details: the decision call has a timeout, and any failure resolves to deny. An authorization system that fails open under load is not an authorization system.
Caching Decisions Without Caching Mistakes
A remote decision on every request is a latency and availability problem, so decisions get cached — and cached decisions are how revoked access keeps working for another ten minutes. The fix is to make the cache key carry the version of everything the decision depended on.
Increment a per-subject version counter whenever that subject’s grants change, and include the policy bundle’s content hash in the key. A revocation then invalidates exactly the affected entries with no broadcast, no cache flush, and no window. The technique, along with the negative-caching pitfalls, is covered in caching authorization decisions at the API gateway.
Data-Level Scoping and Tenant Isolation
The last line of defence is the query itself. Row-level security in the database means a forgotten WHERE tenant_id = ? cannot leak data, because the database applies the predicate whether or not the application remembered to.
-- Deny by default, then scope every read and write to the session's tenant.
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY; -- applies to the table owner too
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
-- Set once per connection checkout, from the verified session — never from a request header.
SET LOCAL app.tenant_id = '550e8400-e29b-41d4-a716-446655440000';
Two details make or break this. Use SET LOCAL inside the transaction so a pooled connection cannot carry one tenant’s context into another’s request — a bug that produces cross-tenant reads only under load, which is the worst way to find it. And add WITH CHECK as well as USING, or a user can read only their own rows while still writing rows labelled with someone else’s tenant.
The same logic applies above the database. A tenant identifier belongs in the verified session, never in a request parameter the client controls, and every list endpoint should be written so the tenant predicate is structurally impossible to omit — a repository layer that requires a tenant-scoped context object beats a code-review convention every time.
Security Hardening and Failure Modes
Authorization defects are rarely exotic. They cluster into a handful of shapes, each with a matching test you can automate.
Beyond the tests, three operational controls matter. Sign policy bundles so a decision point cannot load rules that did not come from your pipeline. Log every decision — subject, resource, action, verdict, and the policy version that produced it — to append-only storage, because “who could access this in March?” is a question you will eventually be asked. And keep a p99 latency budget on the decision path; when authorization gets slow, teams start caching aggressively or bypassing checks on “safe” endpoints, and both undo the design.
The subtlest of these is the fail-open decision path, because it only appears under conditions your tests rarely reproduce. A decision point behind a network hop will eventually time out, return a 5xx, or be temporarily unreachable during a deploy, and the natural-looking catch that logs the error and calls next() turns every one of those moments into an open door. Write the fallback as an explicit, reviewed policy: deny everything, deny writes but allow reads from a cached verdict, or serve a maintenance response — and make a chaos test assert it, because a code comment saying “should fail closed” is not evidence.
Equally common is the check that runs but reads the wrong thing. An enforcement layer that authorises against the identifier in the request path while the handler loads a record by a different identifier — a slug, an external reference, a nested resource id — is checking one object and returning another. The defence is structural: resolve the object once, pass the resolved object to both the authorization check and the handler, and never let the two look it up independently. The same reasoning applies to bulk endpoints, where a caller supplies fifty identifiers and the check is applied to the first one; authorise the set, or filter the set, but do not sample it.
Finally, treat authorization telemetry as a product surface rather than debug output. The denial log should be queryable by subject, resource type, and rule, because the three questions you will actually be asked are “why can this person not do their job?”, “has anyone tried to reach this resource who should not have?”, and “what changed last Tuesday?”. A log that answers those turns authorization from a source of support tickets into a system people trust.
Implementation checklist:
Modelling Permissions That Survive Contact With Product
The technical model is only half the problem. The other half is naming things in a way that still makes sense after two years of feature work, and that is mostly a discipline about granularity and ownership.
Name permissions after capabilities, not screens. A permission called can_see_billing_page dies the moment billing moves into settings; billing:read survives the redesign. Capability names also compose: a role is a set of capabilities, and a new feature adds a capability rather than a role. When someone asks “what can this role do?”, the answer should be a list you can read aloud, not a join across three tables and a switch statement.
Give every permission an owner and a reason. A permission with no owner is never removed, because nobody is willing to be the person who broke something. Record, next to each capability, which team owns it and which product behaviour justifies it; at review time the unowned ones are the first candidates for deletion. This is what makes an access review a half-day exercise rather than a quarter-long project.
Model the resource hierarchy explicitly. Almost every product has an implicit tree — organisation, workspace, project, object — and almost every authorization bug lives at a level someone forgot. Write the hierarchy down, decide at which level each capability is granted, and make the grant table carry that level. “Editor on workspace 12” is checkable; “editor” is not. The hierarchical case, including inheritance and overrides, is covered in modelling hierarchical roles and permission inheritance.
Decide what a deny means. Some systems treat an explicit deny as an override that beats any grant; others have grants only, and absence is denial. Both work, but mixing them produces rules whose outcome depends on evaluation order, which is impossible to reason about and worse to debug. Pick one, write it in the policy’s first paragraph, and make the engine’s default match it.
Keep the permission set small enough to enumerate. If nobody can list the capabilities of your system from memory, neither can the person approving an access request. Twenty to fifty capabilities covers a surprisingly large product; hundreds usually means capabilities were created where attribute conditions belonged.
A Migration Path That Does Not Break Production
Replacing an authorization model in a running system is a data migration and a behaviour change at once, so the safe sequence is to make the new model observable before it is authoritative.
Start by writing the new rules and evaluating them in shadow mode: for every request, compute the legacy verdict and the new verdict, act on the legacy one, and log the disagreements with enough context to reproduce them. A week of production traffic will surface every case your test fixtures missed, and the disagreement rate becomes a burn-down chart with a clear finish line.
Next, flip the decision for a narrow, low-risk surface — an internal tool or a read-only endpoint — while keeping shadow evaluation everywhere else. Watch denial rates rather than error rates: a spike in 403s is the signal that the new model is stricter than the old one in a way real users notice, and it is far easier to diagnose in a single endpoint than across the whole product.
Then expand by resource type rather than by service, because a resource type is the unit your policy is written against and the unit your users think in. Keep the legacy path behind a flag until the disagreement log has been empty for longer than your longest cache TTL, then delete it — a dormant second authorization path is a liability, because the day someone re-enables it for a rollback is the day it grants access your new rules had removed.
Throughout, keep two invariants. Every deny must be attributable: the response, or at least the log line behind it, has to say which rule produced it, or support cannot distinguish a policy bug from correct behaviour. And every grant change must be recorded in the append-only log described in auditing permission changes, so the migration itself is auditable — “who granted this during the cutover?” is a question that gets asked.
Compliance and Standards Alignment
| Requirement | Reference | Evidence to produce |
|---|---|---|
| Access control on every resource | OWASP ASVS V4.1 | Route-to-permission matrix plus the tests that assert it |
| Deny by default | OWASP ASVS V4.1.5 | The policy’s default rule and a negative test |
| Object-level authorization | OWASP API Security API1 | The foreign-object-id test suite and its results |
| Least privilege for roles | ISO 27001 A.9.2 | Role definitions with owners and a review cadence |
| Segregation of duties | SOC 2 CC6.3 | Evidence that no single role can both request and approve |
| Audit trail for grant changes | SOC 2 CC7.2 | Append-only permission-change log with retention |
| Token claims used for authorization | RFC 8725 | Algorithm allowlist and claim-mapping allowlist |
One caveat on evidence: an auditor asking for a route-to-permission matrix wants the one your system actually enforces, not the one in a design document. Generate it from the code — a test that walks the router and prints each route with the permission its middleware requires produces an artefact that cannot drift, and the same test fails loudly the day someone adds an endpoint with no check at all. That single test has caught more real exposure in practice than any amount of policy review, because the failure it detects is an omission rather than a mistake, and omissions are invisible to reviewers reading a diff.
Frequently Asked Questions
Should authorization live in the gateway or in the service?
Both, with different jobs. The gateway rejects traffic that fails coarse rules — unauthenticated, wrong audience, wrong network, obviously wrong role for an entire API surface — which keeps load off your services. The service performs the object-level check, because only it knows that record 4711 belongs to another tenant. Doing only the first is how broken-object-level-authorization findings happen; doing only the second means every service reimplements the coarse rules.
Is putting roles in the JWT a bad idea?
It is a trade, not a mistake. Roles in the token remove a lookup from the hot path, at the cost of staleness: a revoked role stays effective until the token expires. That is acceptable with short access tokens (minutes) and unacceptable with long ones. If you need immediate revocation, keep a version number in the token and compare it against the subject’s current version on each request — one cheap lookup that restores correctness without shipping the whole permission set.
When is relationship-based access control worth the complexity?
When your hardest question involves transitive relationships — a document in a folder shared with a group that contains another group that contains the user. Expressing that in roles or attribute conditions produces queries that are either wrong or unbearably slow, whereas a tuple store answers it with a bounded graph traversal. If your permissions are essentially “role in tenant”, a tuple store is a lot of machinery for no gain.
How do I stop a role explosion once it has started?
Stop creating roles for conditions and start creating them for job functions. Take an inventory of existing roles, cluster the ones that differ by a single condition, and re-express that condition as an attribute rule — region, ownership, time window, data classification. Then delete the redundant roles behind a feature flag, watching your denial logs for the accesses you did not know about. Doing this without an audit log of grant changes is guesswork, which is why the log comes first.
Where should the tenant identifier come from?
From the verified session, and nowhere else. A tenant in a header, a query parameter, or a request body is attacker-controlled input, and treating it as authoritative turns every endpoint into a cross-tenant read. Put the tenant in the session record at login, carry it through your request context, and set it on the database connection inside the transaction. If a user genuinely belongs to several tenants, make switching an explicit, audited action that mints a new session rather than a parameter the client can vary per request.
What is the right latency budget for an authorization decision?
Under 50ms at p99 for a remote decision point, and under 10ms when the verdict comes from a local cache or an embedded engine. The number matters less than what happens when you exceed it: teams route around slow authorization by caching too long or skipping checks on endpoints they judge harmless. Measure the decision path separately from the request it protects, and alert on it, so the pressure to cut corners shows up as a graph rather than as a shortcut in a pull request.
Related
Start with the model that matches your hardest question, then work outwards to enforcement, caching, and audit. Each guide below is written to be implemented on its own, but they assume the deny-by-default baseline and the enforcement split described above.
- Designing role-based access control systems — schema normalization, role hierarchy, and middleware validation for production RBAC.
- Implementing attribute-based access control — context-aware evaluation with subject, resource, and environment attributes.
- Choosing between RBAC and ABAC — a decision framework for when roles stop scaling and attributes take over.
- Integrating Open Policy Agent for authorization — externalize policy as versioned, testable Rego.
- Middleware patterns for permission validation — intercept requests before they reach business logic.
- Policy enforcement points in microservices — where to place enforcement across a distributed system.
- Relationship-based access control with OpenFGA — Zanzibar-style tuples for document sharing and nested groups.