Skip to content

8.4.10 — Authorization Models and the Identity Landscape

Nine pages of protocols answer one question: who is this?

Here is the code that answers the other one:

ts
app.get('/api/orders/:id', requireAuth, async (req, res) => {
  const order = await db.orders.findById(req.params.id);   // (1)
  res.json(order);                                          // (2)
});

(1) The user is authenticated, so the request is allowed. (2) The order is returned — any order, belonging to any customer. Change 1042 to 1041 and you have someone else's address, items and total.

This is insecure direct object reference (also called broken object-level authorization), and it is consistently the most common serious vulnerability in real APIs. No amount of OAuth, SAML or passkeys prevents it, because authentication was never the missing piece.

1. Authentication is not authorization

Authentication establishes identity. Authorization decides what that identity may do.

Three failures follow from confusing them, and they are the three that appear most often in penetration tests:

Object-level failures — the opening example. Authenticated, but not checked against this object.

Function-level failures/api/admin/users protected by not being linked in the interface. The endpoint is reachable by anyone who types the URL.

Client-side enforcement — the button is hidden for non-administrators and the endpoint accepts the request anyway. A hidden control is not a control, and Chapter 6.8.1 makes the same point from the interface side: the interface hides, the server decides.

The rule that prevents all three: every request must be authorised against the specific object and action, on the server, every time. Chapter 8.1's complete mediation is exactly this.

2. The models

Access control lists

Each object carries a list of who may do what. Filesystems work this way.

Precise, and it does not scale organisationally: onboarding means editing thousands of objects, and "what can Ana access" requires scanning everything.

Role-based access control

Users get roles; roles carry permissions; permissions are checked.

Ana → role: finance-approver → permissions: invoice.read, invoice.approve

This is the right default for most systems. It is auditable ("who is an approver" is one query), it maps to how organisations actually think, and it is easy to explain to a customer.

Its two failure modes are worth naming.

Role explosion. Needs get specific — finance-approver-emea-readonly-contractor — and you end up with more roles than users. The cause is almost always encoding scope into the role name. The fix is to make an assignment a triple: (user, role, scope), where scope is a tenant, a project or a region. Ana is approver in EMEA instead of a role called emea-approver.

It cannot express object-level rules. "The owner of a document may edit it" is not a role — it is a relationship, and section 2's later models exist for that.

A role hierarchy (admin inherits editor inherits viewer) removes duplication and needs a rule: inheritance is for convenience, and the check must always resolve to concrete permissions, or you get a hierarchy nobody can reason about.

Attribute-based access control

Decisions from attributes of the user, the resource, the action and the environment.

allow if user.department == resource.department
   and user.clearance >= resource.classification
   and now() within user.workingHours

Powerful, and it costs you the ability to answer questions. "Who can access this document" is no longer a query — it is a search over all users evaluated against a policy. That matters when an auditor asks, and auditors do.

Use it where the rule genuinely depends on data — time, location, amount thresholds, data classification — usually layered on top of roles rather than instead of them.

Relationship-based access control

Google's Zanzibar paper (2019) described how Docs, Drive and YouTube do it, and the model has been widely copied (SpiceDB, OpenFGA, Ory Keto).

Permissions are relationships, stored as tuples:

document:report-2026 #editor @user:ana
folder:finance      #viewer @group:finance-team#member
document:report-2026 #parent @folder:finance          ← (1)

(1) Inheritance is itself a relationship, so "can Ana view this document" is answered by walking the graph: she is not a direct viewer, but the document's parent folder is viewable by a group she belongs to.

Two API shapes make it practical: check(user, permission, object) for a decision, and expand / list-objects for "what can this user see" — which is exactly what you need to render a list without checking a million objects one at a time.

This is the right model when permissions are hierarchical and per-object: documents in folders, repositories in organisations, resources in projects. It is over-engineering for an application whose answer is "editors may edit everything".

Policy engines

Externalise the decision. Open Policy Agent (with the Rego language) and AWS's Cedar let you write policy as code, version it, test it, and evaluate it in one place.

The vocabulary, which appears in enterprise conversations:

  • PEP — policy enforcement point: the code that asks and enforces.
  • PDP — policy decision point: the engine that decides.
  • PIP — policy information point: where extra attributes come from.
  • PAP — policy administration point: where policies are authored.

The genuine benefit is that policy stops being scattered across handlers and becomes reviewable. The genuine cost is a network call in the request path (usually mitigated by running the engine as a sidecar) and a second language for the team to learn. Worth it when policy is complex or must be audited; not worth it for if (user.role === 'admin').

3. Designing one

Permissions are verbs on resource types, not screens: invoice.approve, user.invite, report.export. Naming them after interface elements guarantees churn.

Roles are bundles of permissions, and the set should be small enough to explain on one page.

Scope every assignment. (user, role, scope) — the tenant, project or team it applies in. This is what prevents role explosion.

Deny by default. No permission means no.

No implicit super-user. A superadmin that skips checks becomes the path every incident travels. If you need one, make it an explicit role that is logged loudly on every use.

Let customers define roles, but not permissions. Enterprise buyers want custom roles; they should compose your permissions, not invent new ones.

Multi-tenancy needs a stronger mechanism than discipline. Every query must be filtered by tenant, and "remember the WHERE tenant_id = ?" fails eventually — one forgotten clause is a cross-tenant data leak.

sql
-- Enforce at the data layer so a forgotten WHERE cannot leak
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;               -- (1)
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::uuid); -- (2)

(1) Row-level security makes the database apply the filter. (2) The application sets app.tenant_id once per connection or transaction, and every query is then filtered whether or not the developer remembered. This is defence in depth applied exactly where the failure is most likely, and it pairs well with the connection-pooling caveat from Chapter 7.2.4 — the setting must be applied per transaction when connections are shared.

Enforce at the right layer. Coarse checks (is this user allowed to call this endpoint at all) belong in middleware. Object-level checks belong next to the data, in the function that loads the object, because that is the only place that knows which object it is. A middleware cannot check order.customerId === user.id for an order it has not loaded.

ts
// The fix for the opening bug: load, then authorise, then return
const order = await db.orders.findById(id);
if (!order) return res.sendStatus(404);
if (!can(user, 'order.read', order)) return res.sendStatus(404);   // (1)
res.json(order);

(1) 404 rather than 403 when the existence of the object is itself information — otherwise the endpoint becomes an enumeration oracle telling an attacker which ids are real. Use 403 when the user knows the object exists and is being told they lack permission.

4. Impersonation, elevation and audit

"View as user" is a support feature and an audit problem. It must be a distinct capability, restricted, logged with both identities, time-limited, and visibly indicated to the operator. Logging it as the impersonated user destroys the audit trail — every record must carry who really did this.

Just-in-time access is the modern answer to standing privilege. Nobody holds production administrator rights permanently; they request them, with a reason, for a bounded window, with approval. Most breach severity comes from standing privilege, and this is the control that reduces it.

Access reviews. Periodically, an owner confirms who should still have what. Tedious, required by most compliance frameworks, and it is what actually finds the contractor who left in March.

Log the decision, including denials. A burst of denials is one of the most reliable intrusion signals available, and it is missing from most applications because only successes get logged.

And two queries must be answerable on demand: what can this user access and who can access this resource. If your model cannot answer them, an audit will be painful and an incident will be worse. This is the practical reason to be cautious about pure attribute-based policy.

5. The identity provider landscape

Buy or build is the real question, and it has a clear default.

Build the parts that are your product's logic — your permission model, your roles, your object-level rules. Nobody else can.

Buy authentication. Password storage, MFA, SSO federation, SCIM, session management and the protocol surface of the last nine pages are a large, security-critical, non-differentiating body of work.

Fits
Entra IDMicrosoft estates, employee identity, deep Azure integration
OktaEnterprise workforce SSO, very broad application catalogue
Auth0 (Okta)Developer-facing, customer identity, fast to integrate
KeycloakOpen source, self-hosted, no per-user cost, you operate it
Ping / ForgeRockLarge enterprises, complex federation, on-premises requirements
AD FSOn-premises SAML for Microsoft estates; legacy, being replaced by Entra
AWS CognitoAWS-native customer identity; cheap, and its rough edges are well documented

The costs to price before choosing: per-user pricing at your projected scale (it can become the largest line item), whether SSO and SCIM are gated behind an enterprise tier — the practice sometimes called the "SSO tax", which matters because your enterprise customers will demand both — migration difficulty later, and the fact that their outage is your outage.

Keycloak is the reasonable answer when per-user pricing does not work, on the understanding that you now operate an identity system, including its upgrades and its availability.

An identity-aware proxy sits in front of applications and authenticates every request — Cloudflare Access, Google's IAP, Entra Application Proxy. It is how internal applications get SSO and access policy without modifying them, which makes it the fastest route to protecting a legacy internal estate, and it is the practical expression of the zero-trust idea from Chapter 8.1: no VPN-shaped perimeter, every request authenticated and authorised at the edge of the application.

What the interviewer will push on

"What is the difference between authentication and authorization?" Identity versus permission. Then move immediately to the failure that matters — an authenticated user fetching another user's object, because the check was on the session and not on the object. That is IDOR, and it is the most common serious API vulnerability.

"How would you design permissions for a multi-tenant SaaS?" RBAC with permissions as verbs on resource types, roles as bundles, and assignments scoped as (user, role, scope) so roles do not explode. Tenant isolation enforced at the data layer with row-level security, not by remembering a WHERE clause. Object-level checks next to the data, coarse checks in middleware.

"When would you use ABAC or ReBAC over RBAC?" ABAC when the rule genuinely depends on data — clearance, time, amount thresholds — layered on roles. ReBAC when permissions are hierarchical and per-object, like documents in folders, where you also need "what can this user see" as a query. Then price ABAC honestly: it costs you the ability to answer "who can access this".

"Where do you enforce authorization?" Coarse checks in middleware; object-level checks in the code that loads the object, because middleware cannot compare an owner id for an object it has not fetched. Then the response-code detail: 404 rather than 403 when existence itself is information.

"How do you stop a cross-tenant leak?" Not by discipline. Row-level security or an equivalent at the data layer, so a forgotten filter cannot leak, plus tests that attempt cross-tenant access. Saying "we always filter by tenant" is the answer that precedes the incident.

"Would you build or buy identity?" Buy authentication — passwords, MFA, SSO, SCIM are non-differentiating and security-critical. Build authorisation, because your permission model is your product's logic. Then price the buy: per-user cost at scale, whether SSO and SCIM sit behind an enterprise tier, and that their outage is yours.

One thing to volunteer: point out that a permission model must be able to answer "who can access this resource" as well as "may this user do this". Systems built purely on attribute evaluation cannot, and the question arrives during an audit or an incident — which is the worst possible moment to discover the model does not support it.

Recall

  • Authentication is identity; authorization is the part that gets broken. IDOR — an authenticated user fetching another user's object — is the most common serious API vulnerability, and no login protocol prevents it.
  • Three failure shapes: object-level (not checked against this object), function-level (unlinked admin endpoint still reachable), and client-side only (a hidden button is not a control).
  • RBAC is the right default. Kill role explosion by scoping assignments as (user, role, scope) instead of encoding region or tenant into role names. RBAC cannot express "the owner may edit" — that is a relationship.
  • ABAC decides from attributes and is powerful, but you lose the ability to answer "who can access this". ReBAC (Zanzibar-style tuples with check and list-objects) fits hierarchical per-object permissions. Policy engines (OPA, Cedar) externalise decisions — PEP/PDP/PIP/PAP — worth it when policy is complex or audited.
  • Design: permissions are verbs on resource types, roles are bundles, deny by default, no implicit super-user, and customers compose your permissions rather than inventing them.
  • Enforce tenant isolation at the data layer (row-level security), because a forgotten WHERE is a cross-tenant leak. Coarse checks in middleware; object-level checks next to the data. Return 404 when existence is itself information.
  • Impersonation must be logged with both identities. Just-in-time access removes standing privilege, which is where most breach severity comes from. Log denials — a burst of them is a strong intrusion signal.
  • Buy authentication, build authorization. Price per-user cost, the SSO/SCIM tier gate, and the fact that their outage is yours. An identity-aware proxy adds SSO to applications you cannot modify.

Self-test: Why does adding OAuth not fix the opening bug? · What causes role explosion, and what removes it? · What question can ABAC not answer easily? · Why can't middleware do object-level checks? · When is 404 the correct response to a permission failure? · Which half of identity should you never outsource?

Next: 8.5.1 leaves identity for the attacks themselves — injection in all its forms, and the OWASP Top 10 read as a set of design failures rather than a list to memorise.