Authn and authz: identity is infrastructure, authorization is domain
The two concepts are routinely conflated, which is why they are worth pulling apart first. Authentication — authn — is verifying who someone is. Authorization — authz — is deciding what they are allowed to do. Both are necessary. Both are nontrivial. They have different failure modes, different ownership patterns, and different places where they belong in a system’s architecture.
The mistake most architectures make is treating authz as a subset of authn — something the identity system handles as part of “login.” It is not. Identity is infrastructure: a small, well-understood set of problems with mature standards and vendor solutions. Authorization is domain: the rules about who can do what are specific to your business, they change with your business, and no vendor solves them for you. Offloading identity to a provider is sensible. Offloading authorization to the same provider rarely is.
This post is about both, with enough detail on each to make the working distinctions usable: sessions vs tokens, OAuth2 vs OIDC, RBAC vs ABAC vs ReBAC, and where in a microservice estate the authorization decisions should actually live.
Authentication: sessions vs tokens
The two dominant models for tracking “this is a logged-in user” across a distributed system, with different tradeoffs.
Sessions (server-side state). On login, the server creates a session record — an id, a user reference, an expiry, whatever metadata you want — and returns the id to the client as an opaque cookie. On each request, the client sends the cookie; the server looks up the session to find the user. The session lives in a session store (Redis, the database) and is revoked by deleting it.
Sessions are robust. Revocation is instant — delete the record and the user is logged out everywhere. The client holds no secrets beyond an opaque identifier. The server can update session state freely. The failure mode is operational: the session store is a hot path for every authenticated request, and it must be highly available, partition-tolerant, and fast. At scale, this pushes you toward distributed session stores with their own failure modes.
Tokens (stateless). On login, the server issues a token — a signed blob containing the user id, expiry, and whatever claims the system cares about. The client sends the token on every request. The server verifies the signature and trusts the claims. There is no lookup, because the token carries the state.
The usual concrete form is a JWT (JSON Web Token): a JSON payload,
a signature (symmetric HMAC, or asymmetric RSA/Ed25519), sent as a
bearer token in the Authorization header. Tokens are stateless,
which makes them fast and horizontally scalable — any service can
verify a token without calling the identity service on every
request. The failure mode is revocation: a valid, unexpired token
cannot be invalidated without giving up statelessness.
The standard mitigations for the revocation problem:
- Short token lifetimes (minutes to an hour) paired with refresh tokens (longer-lived, used to get new access tokens). Revoking the refresh token stops further access tokens from being issued. The access token in the user’s hand remains valid until it expires, but the window is bounded.
- Token denylist. Maintain a list of revoked token ids (jtis) that every service checks. This reintroduces the session-store problem in a different shape.
- Rotating signing keys. Rotate the key used to sign tokens occasionally; old tokens signed with old keys become invalid when the key is retired. Useful for periodic mass invalidation.
The practical choice between sessions and tokens comes down to scale and distribution. For a monolith or a small service estate, sessions are simpler and more revocable. For a distributed system where every service must independently verify identity without calling a central authority, tokens are the mature answer — with the caveat that you must accept the revocation tradeoff and design the token lifetime around it.
The worst-of-both-worlds pattern is a long-lived JWT that gets checked against a denylist on every request. You have lost statelessness (the denylist is a lookup) and kept the token’s complexity (JWT parsing, signature verification). If you need revocation on every request, just use sessions.
OAuth2 and OIDC, distinguished
The two are related and routinely conflated. They solve different problems.
OAuth 2.0 is an authorization framework. Specifically, it is a protocol for a user to grant a third-party application access to resources on the user’s behalf, without handing over the user’s password. “Let this app post tweets as me” is the canonical example. OAuth2 is not a login protocol. It does not define how to verify identity. It defines how to issue access tokens that grant specific scopes against specific APIs.
The moving parts:
- Resource owner: the user.
- Client: the third-party application.
- Authorization server: the party that issues tokens (often the platform, e.g., Google’s OAuth server).
- Resource server: the API the token grants access to.
The usual flow (authorization code flow, for web apps) has the user redirected to the authorization server, approving the requested scopes, being redirected back to the client with an authorization code, and the client exchanging the code for an access token via a server-to-server call.
What OAuth2 does not do: tell the client who the user is. The access token grants access to resources; it does not necessarily identify the user to the client. In the original OAuth2 spec, the client had to call a separate API (e.g., “GET /userinfo”) to learn who granted the access.
OpenID Connect (OIDC) is a layer on top of OAuth2 that adds authentication. OIDC standardizes an ID token — a JWT containing standard claims about the user (subject id, email, name) — issued alongside the access token. With OIDC, the client receives both: an access token it can use to call APIs, and an ID token that tells it who the user is.
The working distinction: OAuth2 is delegated authorization, OIDC is federated authentication. “Sign in with Google” is OIDC. “Let this app post to my calendar” is OAuth2. “Sign in with Google and also let this app read my calendar” is both, in the same flow.
When you see a system using “OAuth2 for login,” it is almost always OIDC. Call it OIDC. The precision matters because the two protocols solve different problems, and conflating them produces designs that mishandle the distinction.
Where identity lives
The modern pattern is to separate identity from the services that use identity. Identity is infrastructure: a piece of the platform that every service depends on but none of them owns. The usual shape:
- A dedicated identity service (or provider): Auth0, Okta, AWS Cognito, Keycloak, or a home-rolled equivalent. It knows how to authenticate users (password, MFA, SSO, social), issue tokens, and manage the user directory.
- Services verify tokens using the identity provider’s public keys. Most systems use JWTs with RSA signatures so verification doesn’t require a network call — the public keys are fetched once and cached.
- Services do not implement authentication themselves. They trust the identity provider’s tokens. No passwords are stored in application services. No login UI is implemented per service.
This separation is mature enough that “build your own identity system” is almost always the wrong answer for a new project. The identity providers solve a deep set of problems (credential hashing, MFA flows, session management, account recovery, SSO protocols, threat detection) that most applications should not reimplement. Use a vendor. If regulatory or cost constraints prevent that, use a mature open-source provider (Keycloak, Ory Hydra). Do not roll your own authentication from scratch in 2026.
The boundary is where identity stops and authorization begins. The identity provider says “this token represents user with id X, roles Y, claims Z, valid until T.” What that user is allowed to do in your specific application is where the identity provider stops and your domain starts.
Authorization models: RBAC, ABAC, ReBAC
Three mature models for expressing authorization rules, with different shapes that fit different domains.
Role-Based Access Control (RBAC). Users are assigned roles (admin, editor, viewer). Permissions are granted to roles (admins can delete; editors can write; viewers can read). The rule has two steps: does the user have this role? does this role have this permission?
RBAC is the simplest model and the default in most systems. It works well when the domain’s permissions decompose cleanly by role, when the set of roles is small and stable, and when the access-granting question is “does the user’s role permit this action?” — context-free.
RBAC’s failure mode: role explosion. A system that needs to distinguish “editor of this particular document” from “editor generally” starts creating roles per document. A system with thousands of documents and per-document editors has thousands of roles. The model does not fit.
Attribute-Based Access Control (ABAC). Decisions are made by evaluating a policy over attributes of the user, the resource, the action, and the environment. “Allow if user.department == resource.department AND action.type == ‘read’ AND time.hour < 18.” Policies are arbitrary Boolean expressions over attributes.
ABAC is expressive. Any RBAC policy can be encoded in ABAC (“user has role X” is just an attribute check). Policies can depend on anything relevant — time of day, user location, resource sensitivity, prior actions. This expressiveness makes ABAC the right fit for systems with complex, context-dependent rules (healthcare, finance, government).
ABAC’s failure mode: policy opacity. An expressive policy language is also a hard-to-audit policy language. “Why was this request denied?” becomes a question of evaluating the policy, and policies with many attributes and branches are genuinely hard to reason about. XACML, the standard policy language, has earned a reputation for being unreadable; OPA’s Rego is more modern but still requires discipline to keep understandable.
Relationship-Based Access Control (ReBAC). The model Google described in their 2019 Zanzibar paper, used to power access control for Drive, Calendar, Cloud, and YouTube. Permissions are derived from relationships between users and resources: “user X is an editor of document Y,” “document Y is in folder Z,” “user X is a member of group G,” “group G has viewer access to folder Z.” The authorization decision is a graph traversal over these relationships.
ReBAC is the right model when permissions flow through the graph — folder memberships grant access to contained documents, team memberships grant access to team resources, organizational roles inherit across subunits. Zanzibar-style systems represent billions of relationships and answer check queries (can X do Y to Z?) in milliseconds. Modern ReBAC implementations — Authzed/SpiceDB, Ory Keto, OpenFGA — are the standard tooling.
ReBAC’s failure mode: mental model. Teams that have never thought about authorization as a graph take time to model their domain this way. The upfront modeling is real work. The payoff is a system where rules like “anyone who can view the parent folder can view this document” are one relationship away instead of a custom policy rule per permission.
The practical pattern in most modern systems: RBAC for coarse-grained permissions, ReBAC for fine-grained permissions, ABAC for context-dependent rules that neither handles cleanly. The three are not mutually exclusive; real systems layer them.
Where authorization decisions actually live
Given that authorization is domain, the question of where in the code the decisions are made is a real architectural choice.
At the gateway. The API gateway inspects the token, extracts claims, and either allows or denies the request before forwarding to the service. This works for coarse-grained rules (“must be authenticated,” “must have role X”). It does not work for fine-grained rules (“can edit this document”), because the gateway does not know the resource.
At the service boundary. The service receives the request, looks up the resource being accessed, and checks whether the caller is authorized. This is where most fine-grained checks live. It requires the service to know the authorization rules for the resources it owns.
In the domain. Authorization rules are part of the domain
model itself: an Order aggregate knows who can place it, who
can cancel it, under what conditions. The service dispatches the
command; the aggregate enforces the rule. This is the DDD-aligned
approach, and it is usually the right place for rules that are
part of the business logic rather than the infrastructure.
In an authorization service. A dedicated service (SpiceDB,
OpenFGA, OPA as a sidecar) answers check(user, action, resource) queries. Each service calls the authorization service
on each request. The rules live centrally; the enforcement is
distributed. This is the Zanzibar shape and is increasingly
common in complex microservice estates.
The first three can coexist: gateway-level authentication, service- level coarse checks, domain-level fine-grained rules. The fourth is usually a pattern-level choice: a team commits to a centralized authorization model for a class of resources, and every service that owns such resources calls the authorization service.
The common mistake: scattering authorization checks through the codebase without a clear layering. “Did we check permissions for this?” becomes a question nobody can answer without reading every code path. The discipline is to have a consistent answer to “where authorization lives for this resource” and to enforce it.
Policy-as-code and OPA
For ABAC and for complex mixed models, the dominant approach is policy-as-code: authorization rules written in a dedicated language, evaluated by a policy engine. Open Policy Agent (OPA) is the incumbent, with its Rego language. Amazon’s Cedar is a newer entrant with different tradeoffs (simpler, more analyzable, less expressive).
The appeal is real. Policies live in version control, are reviewable, are testable, and are decoupled from the application code. A security team can own and change the policies without redeploying services. Policies can be evaluated at multiple enforcement points (gateway, service, sidecar) by reading the same source file.
The cost is another system to run. OPA is not especially heavy, but it is a dependency with its own failure modes (policy bundle updates, sidecar availability, decision logging). The policies themselves can become complex enough to need their own testing and review discipline. “Policy as code” is not magic; it is a second kind of code that needs all the care the first kind does.
For systems with genuinely complex authorization rules, policy-as- code is the right direction. For systems with RBAC and a dozen permissions, it is overkill, and inline checks are clearer.
The rule
Identity is infrastructure. Buy it. Use an identity provider (hosted or open-source) that handles authentication, MFA, SSO, and user management. Do not reimplement these. Make every service trust the identity provider’s tokens without implementing login itself.
Authorization is domain. Own it. Model your permissions with the structure that fits the business — RBAC for simple, ReBAC for relationship-heavy, ABAC for context-dependent — and be honest about which one you actually have. Put the fine-grained checks close to the domain, in the service that owns the resource or in a centralized authorization service that every service calls. Keep the policies readable, testable, and versioned.
The most common authz bug is an authorization check that was forgotten, and the second most common is one that checked the wrong thing. Both are the result of authz being scattered rather than structured. The structure — whatever model you pick — is what makes the checks survive code changes, team changes, and attacker curiosity.
Identity is who. Authorization is what. Keep them separate. Get both right. Most systems get one right and the other approximately right; the result is either a system where anyone who gets in can do anything, or a system so locked down that real users can’t work. Neither is acceptable. The discipline is the same as every other piece of architecture in this blog — deliberate, explicit, testable, documented. There are no shortcuts that have aged well.