Authorization models · How it works
23 min read- How it works
- Where the check can live
- 1. Self-contained token (JWT) + refresh-token rotation
- 2. JWT + jti denylist
- 3. Token versioning (security stamp)
- 4. Gatekeeper / API gateway
- 5. Phantom token
- 6. Reference token + introspection (RFC 7662)
- 7. Policy-based access control (PDP / PEP, e.g. OPA or Cedar)
- 8. Relationship-based access control (ReBAC, Zanzibar-style)
- 9. UMA 2.0 (User-Managed Access)
- 10. Resource-scoped RBAC
- 11. OAuth 2.0 client credentials (machine-to-machine)
- 12. Casbin (embedded policy library)
- Comparing the designs
- Combining tenancy and authorization
- A worked example: grants inside a pooled tenant
How it works
Where the check can live
A check can happen at any hop, and real systems usually check coarsely early (is this token valid, does it have the orders scope) and finely late (may this user edit this order).
1. Self-contained token (JWT) + refresh-token rotation
In plain words: the server hands the client a signed note listing who they are and what they may do; any service can read it without asking anyone, and the note expires quickly.
- Check lives: in each service, by verifying the signature and reading claims.
- Revoke latency: up to the access-token lifetime (typically 5-15 minutes); the refresh token is revoked at once, and rotation (a new refresh token on every use, reuse detected as theft) limits a stolen one.
- Pros: no lookup per request; scales trivially; works across services and offline.
- Cons: cannot be recalled before expiry; permissions in the token go stale when roles change; tokens grow if you stuff them with permissions; claims are readable by the client.
- Who uses it: the default in OAuth 2.0 / OpenID Connect deployments (Auth0, Okta, Keycloak, Microsoft Entra ID); Firebase Authentication issues one-hour ID tokens that the SDK silently refreshes with a long-lived refresh token.
- Sources: RFC 9068 — JWT profile for OAuth 2.0 access tokens · OAuth 2.0 Security Best Current Practice — refresh token rotation · RFC 6749 — refresh token grant · Auth0 — refresh token rotation
A: Because an access token cannot be recalled once issued — any service that trusts the signature will accept it until it expires — so its lifetime is the window in which a stolen or out-of-date token still works. Keeping that to minutes bounds the damage. The refresh token is only ever presented to the authorization server, which can check it against its own store on every use, so it can be revoked instantly and can safely live longer. Rotation adds theft detection: each use returns a new refresh token, and if an old one is presented again the server knows two parties hold it and revokes the whole family.
2. JWT + jti denylist
In plain words: keep self-contained tokens, but keep a short list of token ids that were cancelled early, and reject any token on that list.
- Check lives: each service (or gateway) checks the token's
jtiagainst a shared, fast store such as Redis. - Revoke latency: seconds — as soon as the entry replicates.
- Pros: instant logout and revocation while keeping JWTs; the list only holds tokens revoked before expiry, so it stays small (entries expire with the token).
- Cons: reintroduces a lookup on every request, which is the thing JWTs were meant to avoid; the denylist becomes a dependency (fail open or closed?); only works for tokens you know to cancel, not "everything this user has"; it kills a whole login, so it cannot withdraw one permission while leaving the rest of the session alive.
- Who uses it: common in custom JWT stacks that need a working "log out everywhere" or account-suspension button; Auth0's blog documents a
jti-keyed denylist, and Okta exposes a standard/revokeendpoint for the same job. - Sources: RFC 7519 — the
jticlaim · OWASP — JSON Web Token cheat sheet, token revocation · RFC 7009 — OAuth 2.0 Token Revocation
3. Token versioning (security stamp)
In plain words: each user has a version number; tokens carry the number they were issued with, and bumping the user's number invalidates all of their older tokens at once.
- Check lives: each service compares the token's stamp with the current stamp (cached).
- Revoke latency: as fast as the stamp cache refreshes — seconds to a minute.
- Pros: one write revokes every token of a user (password change, role change, suspension); the store is one small value per user, not per token.
- Cons: still a lookup (cacheable); cannot revoke a single device without revoking all; cache TTL is your revoke latency.
- Variant — permission version: bump the number on every grant or membership change, not only on security events. The token stays identity-only (who you are plus a version); permissions are read from the grants store and cached; a request whose token version is older than the user's current version is rejected, so a removed grant bites on the next request after the cache hears about it. The check is one integer compare from memory. The hard part is the change signal across services: poll a small version table every second or so, or publish a change event.
- Who uses it: ASP.NET Core Identity's
SecurityStampis exactly this; many session systems do the same with a per-user "sessions valid after" timestamp. - Sources: ASP.NET Core Identity — SecurityStampValidator · OWASP — Session Management cheat sheet · Microsoft Learn — SecurityStampValidator · Microsoft Learn — cookie auth, reacting to back-end changes
A: A denylist revokes individual tokens: you add the ids of tokens you want dead, which is precise (log out one device) but means you must know every token id to kill all of a user's sessions. Token versioning revokes by user: each token carries the user's stamp at issue time, and bumping the stamp kills every older token in one write, which is ideal for password resets, role changes and suspensions but cannot target one device. Both add a lookup per request that you cache, and in both the cache lifetime is your actual revoke latency. Many systems use versioning for "everything this user has" and short token lifetimes for the rest.
4. Gatekeeper / API gateway
In plain words: one front door checks every request before it reaches any service, so services behind it can trust what arrives.
- Check lives: in the gateway (token validation, scopes, rate limits, coarse rules).
- Revoke latency: depends on what the gateway checks — introspection makes it instant, JWT validation makes it token-lifetime.
- Pros: one place to enforce authentication and coarse authorization; services stay simpler; consistent logging and throttling; it also covers old endpoints that never got their own checks.
- Cons: only coarse checks — the gateway does not know whether this user owns order 42; services that trust the gateway blindly are exposed if anything bypasses it (internal traffic, misrouted ingress); a single choke point to scale.
- Who uses it: Kong, AWS API Gateway, Azure API Management, Envoy-based gateways; AWS API Gateway runs Lambda-authorizer decisions with a first-class result cache; the Gatekeeper pattern is documented by Microsoft.
- Sources: Azure Architecture Center — Gatekeeper pattern · Azure — Gateway Offloading pattern · AWS API Gateway — Lambda authorizers and caching · Envoy — ext_authz filter · Kong — ACL plugin
5. Phantom token
In plain words: the outside world gets a meaningless random token; the gateway swaps it for a full JWT on the way in, so internal services get rich claims and the client sees nothing.
- Check lives: the gateway introspects the opaque token (and caches the result), then forwards a JWT inward.
- Revoke latency: as short as the gateway's introspection cache.
- Pros: nothing sensitive leaks to clients; revocable at the edge; internal services keep the convenience of JWTs.
- Cons: needs a gateway that can introspect and cache; one more moving part; internal JWTs still need short lifetimes; a gateway plus a token service is heavy for a system of two or three services.
- Who uses it: a pattern promoted by Curity, which ships it as production plugins for Kong, NGINX and AWS Lambda authorizers and counts several Nordic banks as customers.
- Sources: Curity — The Phantom Token approach · RFC 7662 — OAuth 2.0 Token Introspection · Curity — Kong phantom-token plugin
A: It combines the revocability and privacy of opaque tokens outside with the convenience of JWTs inside. Clients receive a random reference token that reveals nothing if intercepted or inspected. At the gateway that token is introspected against the authorization server — so revocation takes effect at the edge as soon as the introspection cache expires — and replaced with a signed JWT carrying the claims internal services need, so those services never make their own introspection calls. The cost is a gateway that can do the exchange and cache it.
6. Reference token + introspection (RFC 7662)
In plain words: the token is just a random ticket number; every service asks the authorization server "is this ticket still good, and whose is it?"
- Check lives: at the authorization server, called by the resource server (usually with a short cache).
- Revoke latency: immediate (or the introspection cache TTL).
- Pros: instant revocation; no claims exposed; permissions are always current.
- Cons: a network call per request (or per cache miss); the authorization server is on the hot path and must scale and stay up; ruled out outright when a service must decide without calling another service per request.
- Who uses it: supported by Keycloak, IdentityServer / Duende, Okta and most OAuth servers; common in banking APIs; Zalando's Skipper router implements RFC 7662 introspection in production.
- Sources: RFC 7662 — OAuth 2.0 Token Introspection · Duende — reference tokens · oauth.com — token introspection endpoint · RFC 8414 — authorization server metadata
7. Policy-based access control (PDP / PEP, e.g. OPA or Cedar)
In plain words: rules are written as code in one place (the decision point), and every service asks it "may this user do this to that?" instead of hard-coding the rule.
- Check lives: the enforcement point (PEP) in the service or sidecar calls the decision point (PDP), often a local sidecar with policies pushed to it.
- Revoke latency: as fast as policy and data are distributed to the PDPs — seconds.
- Pros: rules are versioned, reviewed and tested like code; attribute-based (time, location, tenant, resource state); one language across services; decisions are auditable.
- Cons: the PDP needs the data the rule depends on (user attributes, resource owner), which means syncing data into it; a new language and a new service to operate; hard to answer "list everything this user can see"; a sidecar call costs milliseconds per decision; SDK coverage differs by language, so check your stack has a maintained binding before choosing an engine.
- Who uses it: Open Policy Agent (CNCF, used widely in Kubernetes admission and service auth; Netflix is a listed adopter across its microservices); AWS Verified Permissions uses the Cedar language.
- Sources: Open Policy Agent documentation · Cedar policy language · NIST SP 800-162 — Attribute Based Access Control
8. Relationship-based access control (ReBAC, Zanzibar-style)
In plain words: access is a graph — Alice is a member of Team X, Team X is an editor of Folder Y, Folder Y contains Doc Z — and the question "can Alice edit Doc Z?" is answered by walking it.
- Check lives: a central relationship service (OpenFGA, SpiceDB, Ory Keto) that stores tuples and answers checks.
- Revoke latency: as soon as the tuple is deleted (consistency tokens let callers demand fresh reads).
- Pros: models sharing, hierarchies and cross-tenant collaboration naturally; answers both "can X do Y" and "what can X see"; one permission model for many services.
- Cons: every relationship change must be written to the service (dual writes with your database), so the graph becomes a second source of truth; a new server and datastore to run; a hot dependency on the request path; modelling takes real design work.
- Who uses it: Google Zanzibar (Drive, YouTube, Cloud); OpenFGA (from Auth0/Okta); SpiceDB (AuthZed), which Turo runs in production at tens of millions of checks a day; Airbnb's Himeji.
- Sources: Google — Zanzibar paper (USENIX ATC 2019) · OpenFGA documentation · SpiceDB documentation
A: When access depends on relationships to specific objects rather than on a job title. RBAC answers "admins may delete orders" well; it answers "Alice may edit this document because she is in a team that was given editor rights on the folder containing it" badly, because you end up minting a role per object and the role table explodes. ReBAC stores exactly those relationships as tuples and computes the answer by following them, which handles sharing, nested groups, folder inheritance and users who collaborate across organisations. The trade is a central service on the request path and the discipline of writing every relationship change to it.
9. UMA 2.0 (User-Managed Access)
In plain words: the owner of a resource decides who else may use it, and the authorization server enforces those owner-set grants.
- Check lives: the authorization server issues permission tickets and requesting-party tokens (RPTs) scoped to specific resources.
- Revoke latency: immediate at the server; RPT lifetime at the resource.
- Pros: standard protocol for owner-controlled sharing across parties; fine-grained, per-resource permissions without custom code.
- Cons: complex protocol with few client libraries; two or more extra round trips to obtain a permission token, which can grow to tens of kilobytes; the resource server must register resources; usually means moving every user into the identity server first; relatively rare outside health and consent-heavy domains.
- Who uses it: Keycloak Authorization Services implement UMA 2.0; used in health data and consent platforms; Keycloak itself also serves as the OAuth authorization server for some open-banking API platforms (Hitachi has published one).
- Sources: Kantara — UMA 2.0 Grant for OAuth 2.0 · Keycloak — Authorization Services · Kantara — UMA 2.0 Federated Authorization
10. Resource-scoped RBAC
In plain words: roles are given per thing, not globally — Alice is Admin of Project A and Viewer of Project B.
- Check lives: in the service, reading a role-assignment table keyed by (user, resource, role), usually cached.
- Revoke latency: as soon as the assignment row is removed (plus cache TTL).
- Pros: simple to understand and audit; fits most B2B products (workspace, project, account scopes); easy "who has access" reports; usually the closest to what an app already has, with one source of truth and no new server.
- Cons: role explosion when rules depend on attributes; inheritance (org → project → item) must be coded by hand; role checks scattered in code drift unless centralised; a cached grant outlives its deletion until the cache hears about it, so instant revoke needs token or permission versioning on top; a cold cache costs one database read.
- Who uses it: GitHub (organisation and repository roles), Azure RBAC (roles assigned at a scope), Google Cloud IAM.
- Sources: Azure RBAC — scope · NIST — Role Based Access Control · OWASP — Authorization cheat sheet · GitHub Docs — organization roles · GitLab — permissions
11. OAuth 2.0 client credentials (machine-to-machine)
In plain words: a service or integration logs in as itself with its own id and secret (or certificate) and receives a token for the APIs it is allowed to call — no user involved.
- Check lives: the API validates the token's audience and scopes as for any token.
- Revoke latency: disable the client at once; outstanding tokens live until expiry.
- Pros: standard, supported everywhere; separate identity per integration so access and audit are per client; no shared user passwords in scripts.
- Cons: secrets must be stored and rotated (prefer certificates or
private_key_jwt); easy to over-grant scopes; a leaked client secret is a standing credential. - Scoping it to tenants: give the client rows in the same grants table as users, so an integration that serves one tenant sees only that tenant's data and loses it the moment the grant is removed.
- Who uses it: every OAuth provider; the standard for partner APIs, service-to-service calls and scheduled jobs; Spotify's Web API documents it for server-to-server calls.
- Sources: RFC 6749 §4.4 — Client Credentials Grant · RFC 7523 — JWT client authentication · Microsoft Entra — client credentials flow · Spotify — client credentials flow
A: Because it gives the integration its own identity with exactly the scopes it needs, which can be audited, rotated and disabled without touching any person's account. A shared service-user password sits in scripts and config, usually has a human-sized permission set far larger than the job needs, often cannot use MFA, and when it leaks every integration using it must change at once. With client credentials each integration has a separate client, secrets can be replaced by certificates or signed assertions, and revoking one partner is a single switch.
12. Casbin (embedded policy library)
In plain words: a library inside your service reads a small model file (RBAC, ABAC or a mix) and a policy table and answers access questions in-process.
- Check lives: in-process, in each service, with policies loaded from a shared store.
- Revoke latency: as fast as policies reload in each instance (a watcher can push updates).
- Pros: no network call; supports RBAC with domains (tenants), ABAC and custom models; many languages.
- Cons: its own rule language for the team to learn; every instance holds the policy set in memory; keeping instances in sync needs a watcher; less suited to huge relationship graphs than a Zanzibar service.
- Who uses it: widely adopted in Go, Node and .NET services and API gateways that want embedded authorization; Docker is a listed adopter for plugin access control.
- Sources: Casbin documentation · Casbin — RBAC with domains · Casbin.NET on GitHub
Comparing the designs
Costs are rough estimates, not benchmarks.
| Design | Token state | Cost per request | Complexity | Revoke delay |
|---|---|---|---|---|
| Self-contained JWT + refresh rotation | stateless | ~0 | low | up to the token lifetime (5-15 min) |
| jti denylist | hybrid | one cache read | low | instant for a token, not for one permission |
| Token / permission versioning | hybrid | one integer compare | medium | about the version-signal interval, grants included |
| Gatekeeper gateway | stateful edge | one extra hop | high | gateway cache lifetime |
| Phantom token | stateful | one hop, cached | high | introspection cache lifetime |
| Reference token + introspection | stateful | HTTP call to the auth server | medium | cache lifetime (seconds) |
| Policy engine (OPA, Cedar) | any | local call, milliseconds | high | policy/data push delay |
| ReBAC (OpenFGA, SpiceDB) | any | gRPC/HTTP check | high | as soon as the tuple is deleted |
| UMA 2.0 | stateful | two or more round trips | high | permission-token lifetime |
| Resource-scoped RBAC, cached | hybrid | memory hit; one DB read on a miss | low | cache lifetime alone; near-instant with versioning |
| Client credentials | stateless | ~0 | low | disable the client; tokens live to expiry |
| Casbin (embedded) | any | in-process | medium | policy reload signal |
Combining tenancy and authorization
Pick the tenancy model and the authorization model together, because each constrains the other.
| Tenancy | Good authorization pairing | Why |
|---|---|---|
| Pool (+ RLS) | JWT with tenant_id claim + resource-scoped RBAC, RLS keyed on the same claim | tenant comes from a signed claim; RLS enforces it again in the database |
| Pool with cross-tenant users | ReBAC (OpenFGA / SpiceDB) | memberships in several tenants are relationships, not a single claim |
| Silo | Reference tokens + introspection, catalogue lookup per tenant | routing and revocation both go through a central check |
| Stamps | Per-stamp gateway with phantom tokens | the edge exchanges tokens and routes to the right stamp |
| Any, with partner integrations | Client credentials per partner, scopes per tenant | machine access is audited and revocable per partner |
Which pairs work (✔ good fit, ~ works with extra cost, ✘ fights the tenancy model):
| Tenancy ↓ · Authorization → | JWT + denylist | Scoped RBAC + versioning | Gatekeeper | Policy engine | ReBAC | UMA / IdP permissions token |
|---|---|---|---|---|---|---|
| Pool / pool + RLS | ~ no per-item scope | ✔ best fit | ~ extra hop | ~ sidecar | ~ second store | ~ identity migration |
| Bridge | ~ | ✔ per schema | ~ | ~ | ~ | ~ |
| Silo | ✔ token names the tenant | ✔ roles per database | ~ routes to the database | ~ | ✘ graph across databases | ~ |
| Stamps | ✔ per stamp | ~ per stamp | ✘ one gateway per stamp | ✘ one engine per stamp | ✘ one store per stamp | ✔ one realm per stamp |
| Tenant sharding | ~ | ✔ as pool | ~ | ~ | ~ | ~ |
Five complete combinations, each an end-to-end answer:
- Pool + RLS, resource-scoped RBAC, permission versioning, header tenant selector, client credentials for integrations. One database with a tenant column and RLS, grants in the same database, a version number for near-instant revoke. Fits: up to thousands of tenants on one database. Pros: no new server; one truth for tenant and grants; an integration can span tenants. Cons: the RLS context and ORM filter must be set on every request.
- Pool, ReBAC, header tenant selector. Data in one database, a relationship service for who may see what. Fits: many cross-tenant shares and deep hierarchies. Pros: sharing and inheritance come built in. Cons: a second store to keep in sync and another server to run.
- Silo, self-contained token naming the tenant, RBAC inside each database. The token names one tenant, the catalogue picks its database, roles live inside it. Fits: a few large tenants that demand their own data. Pros: a hard data wall; per-tenant restore. Cons: a multi-tenant integration needs one token per tenant; N migrations.
- Stamps, an identity realm per stamp, host-based tenant resolution. Each tenant has its own URL, identity realm and full stack. Fits: a regulated customer demanding separate hosting. Pros: total isolation. Cons: N stacks and N realms; nothing is shared across tenants.
- Pool, identity-provider organisations + UMA permission tokens, tenant in the token. The identity server holds tenants as organisations and issues tokens with the organisation and permissions. Fits: you are moving identity to an external IdP anyway. Pros: SSO, MFA and membership in one product. Cons: user migration; extra round trips per permission ticket.
Refuse on the constraint first. Instead of comparing every option on every axis, take the hardest requirement and cross out what fails it. "Access must end within one second of revocation" removes plain JWTs (minutes) and leaves introspection, phantom tokens, a denylist or versioning with a tiny cache. "A user belongs to several tenants" removes a single tenant_id claim as the whole answer. Only then compare what is left on cost and complexity.
A: Only designs that consult a live source on each request (or with a sub-second cache): reference tokens with introspection, phantom tokens with a very short gateway cache, a jti denylist, or token versioning with a very short cache — plus relationship or policy engines whose data updates propagate within the second. Plain self-contained JWTs do not qualify, because a valid token is accepted until it expires, typically minutes. The trade you then state is latency and availability: every request now depends on a lookup, so that store must be fast, replicated, and you must decide whether it fails open or closed.
A: Coarse checks as early as possible, fine checks where the data is. The gateway or middleware rejects invalid tokens, wrong audiences and missing scopes cheaply before any business code runs. Whether this user may act on this particular record can only be decided by something that knows the record — the service, a policy engine given the resource attributes, or a relationship service — and for tenant boundaries the database can enforce it again with row-level security. Relying only on the gateway is the classic mistake, because it cannot see ownership and anything that reaches the service by another route skips it.
A: RBAC — permissions come from roles assigned to a user, optionally per scope ("admin of project A"). ReBAC — permissions come from relationships in a graph ("editor of the folder that contains this doc"). PBAC/ABAC — permissions come from rules evaluated over attributes of the user, resource and context ("managers may approve invoices under 10,000 in their own region during business hours").
A worked example: grants inside a pooled tenant
Tenant A owns Projects 1 and 2; Tenant B owns Project 3. User 1 is a viewer of Project 1 only; User 2 is an editor of all of Tenant A; an integration client holds a viewer grant on Tenant B. Grants are rows of (subject, role, scope type, scope id).
| # | Who | Grant row | Action | Result |
|---|---|---|---|---|
| 1 | User 1 | viewer · project · 1 | read Project 1 | allowed |
| 2 | User 1 | viewer · project · 1 | read Project 2 | 403, no grant |
| 3 | User 1 | viewer · project · 1 | edit Project 1 | 403, viewer is read-only |
| 4 | User 2 | editor · tenant · A | edit Project 2 | allowed, inherited from Tenant A |
| 5 | User 2 | editor · tenant · A | read Project 3 | 403 (or 404), other tenant |
| 6 | Integration | viewer · tenant · B | read Project 3 / Project 1 | allowed / refused |
| 7 | User 1 | row deleted by an admin | read Project 1 with the same token | refused on the next request once the version bump arrives |
| 8 | Platform admin | global admin flag | anything | allowed, and audited |
Case 7 is the one plain JWTs fail: without a version check the old token keeps working until it expires.
A: Because in practice the grants are cached, and a cache makes a deleted grant live on until the entry expires. The version gives every cache a cheap way to know it is stale: when any grant or membership of the user changes, their version is bumped, services learn about it through a small change signal, and a request carrying an older version is rejected or forces a reload. That turns revoke latency from "cache lifetime" into "signal interval" for the cost of one integer compare, without putting permissions in the token or calling another service per request.
A: Resource-scoped RBAC in the same database as the data, with a permission version for fast revoke, a validated tenant selector per request, and client credentials for the partner. The token stays identity-only and short-lived; grants of (subject, role, scope) are cached in each service; memberships decide which tenants a caller may select; the partner is a client with grants on exactly its two tenants. I would refuse introspection first if services must not call the auth server per request, and hold ReBAC or a policy engine back until sharing rules outgrow roles, because both add a second store or service to run.