Securityauthorizationpermissions403object-levelaccess control

Authorization Design in the Contract

Every operation needs a documented answer to "who may call this?" — and the enforcement must check the *object*, not just the endpoint. Missing object-level checks are the most exploited API flaw in the wild, and the contract decides whether denial reads as 403 or 404.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
For every operation and every resource id in it: who is allowed, how does a consumer discover that from the docs, and what exactly happens when the answer is no?
Consumers
Integrators deciding whether their token can do what their feature needs before they build it; security reviewers reading the contract for holes; end users whose data the permission model is actually protecting.
The promise
A permission model consumers can read: each operation documents its required permission, denial semantics (403 vs 404) are deliberate and consistent, and access to `/things/{id}` is checked against the caller's relationship to *that* id on every request.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

"Who may call DELETE /projects/{id}?" is a contract question

The mechanisms of access control — role models, policy engines, how checks execute — belong to Security Engineering (Authorization Models, Role-Based Access Control, ABAC and Policy-Based Authorization). The contract question is smaller and sharper: for each operation, *what must be true of the caller* for it to succeed, and where does a consumer read that? DELETE /projects/{id} might require "project admin", "org owner", or "the user who created it" — all defensible; what is not defensible is the answer existing only in the implementation, forcing consumers to discover permissions by probing production.

Document the required permission per operation, in the operation's own docs, in the vocabulary the API exposes (roles, Scopes: Least Privilege as Contract Surface, relationships): requires: project role ≥ admin, scope projects:write. This is also a design forcing-function — if you cannot state an operation's permission rule in one line, the permission model itself is too tangled for consumers to build UIs against, and their only alternative is calling the API and seeing what happens. APIs that add a permissions field on resources ("can_delete": false) go one step further and let clients disable buttons honestly instead of replicating your policy engine badly.

Permission clauses, written where the operation is documented
GET    /projects/{id}            requires: project member
PATCH  /projects/{id}            requires: project role ≥ editor
DELETE /projects/{id}            requires: project role = admin
POST   /projects/{id}/members    requires: project role = admin
                                            scope projects:write
GET    /projects/{id}/billing    requires: org role = owner
                                            scope billing:read

denial: 404 if caller is not a member (existence hidden)
        403 with error.code if member lacks the role

The endpoint is not the object: check the {id}

The deadliest authorization bug in APIs is structural, not cryptographic: the middleware verifies the caller is authenticated and may use the *endpoint*, and nobody verifies they may access the *object* the id names. GET /invoices/9231 returns invoice 9231 to any logged-in user, and an attacker who owns invoice 9230 just increments the number. This is broken object-level authorization — Broken Access Control (IDOR / BOLA) is the OWASP-topping category it belongs to — and it thrives because every individual piece works: authn passes, routing passes, the handler runs; the missing check is invisible until someone hunts for it.

The design rule: authorization takes two inputs, the operation *and* the specific resource, on every request. Sequential integer ids make the flaw trivially farmable (enumerate 9000–9999), and random ids (UUIDs) merely slow the harvest — unguessable ids are not access control, because ids leak through logs, exports, support tickets and referrers. The relationship check — does this caller have access to this row — is the control; everything else is camouflage. For list endpoints, the same rule wears different clothes: GET /invoices must be scoped to the caller's tenant in the query itself, not filtered after fetching, or one missed filter becomes a cross-tenant data spill (the API-layer face of Multi-Tenant Isolation).

Endpoint-level check only — the id is trusted
1def get_invoice(req, id):
2 require_authenticated(req) # who are you? ✓
3 require_scope(req, "invoices:read") # may you use
4 # this endpoint? ✓
5 return db.invoices.get(id) # may you see THIS
6 # invoice? …never asked
7
8# attacker with any valid account:
9# for i in range(9000, 9999): GET /invoices/{i}
Object-level check on every id, denial hides existence
1def get_invoice(req, id):
2 require_authenticated(req)
3 require_scope(req, "invoices:read")
4 inv = db.invoices.get_for_account(
5 id=id, account=req.principal.account_id)
6 # WHERE id = :id AND account_id = :acct
7 if inv is None:
8 return 404 # absent and forbidden
9 # are indistinguishable
10 return inv

The good version makes the caller's relationship to the row part of the lookup itself — there is no code path that fetches first and decides later, so there is nothing to forget. The 404 for foreign ids means enumeration cannot even confirm which ids exist.

403 or 404: what denial should reveal

When the caller may not access /projects/{id}, the contract has to pick what they learn. A 403 says "this exists, and you may not see it" — honest, debuggable, and an existence oracle: an attacker can now map which ids are real, which customers exist, which invoice numbers are live. A 404 says nothing — maximum privacy, and every misconfigured integration burns an afternoon debugging a "missing" resource that actually exists. Neither is universally right; what is wrong is deciding per-endpoint by accident, so that one endpoint's 403 confirms what another's 404 tried to hide.

A defensible policy: 404 when the caller has no relationship to the resource at all (a foreign tenant's project — existence itself is the secret); 403 with a machine-readable code (error.code: "insufficient_role") when the caller is inside the boundary but lacks the level — a project *member* attempting an admin action, where existence is already known and actionable feedback ("ask your admin") is worth more than concealment. Whichever line you draw, write it down and enforce it uniformly; An Error Taxonomy Clients Can Branch On gives denial its stable shape, and consistency is what keeps the privacy property real. And design for permission *evolution*: express requirements as named capabilities in the contract (projects:delete) rather than hardcoded role names, so adding a "maintainer" role between editor and admin re-maps capabilities instead of re-documenting every operation.

Key points

  • Every operation documents who may call it, in the API's own vocabulary (roles, scopes, relationships) — a rule you cannot state in one line is a model consumers cannot build against.
  • Authorization takes two inputs — operation and object. Endpoint-level checks alone are broken object-level authorization, the most exploited API flaw category.
  • Make the relationship check part of the lookup (WHERE id = :id AND account_id = :acct), so there is no fetch-then-decide path to forget.
  • Unguessable ids are not access control; ids leak through logs, exports and tickets. Scope list endpoints in the query itself, not by post-filtering.
  • Pick 403-vs-404 semantics deliberately: 404 outside the relationship boundary (hide existence), 403 with a code inside it (actionable feedback) — and apply it uniformly.
  • Express permissions as named capabilities so new roles re-map capabilities instead of re-writing every operation's docs.

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → middleware: implements authn plus per-endpooint scope checks and calls authorization done.
  2. 2
    Handlers → database: every GET /things/{id} fetches by id alone; the caller's relationship to the row is never consulted.
  3. 3
    Attacker → API: signs up for a free account, enumerates sequential ids across invoices, orders and exports.
  4. 4
    Data → attacker: thousands of foreign records read at normal request rates — no error spike, no auth failures, nothing anomalous to alert on.
  5. 5
    Provider → disclosure: the breach surfaces via a researcher or a customer; the fix is one WHERE clause, the incident is a regulatory filing.
What breaks
  • Cross-tenant data exposure — other customers' records readable or writable by id, the canonical API breach.
  • Silent privilege escalation: operations documented as admin-only callable by members, discovered by whoever tries.
  • Inconsistent denial semantics leak existence maps even where object checks exist, and make every integration's error handling endpoint-specific.

Design, observe, evolve

A contract decision is incomplete until you know how you would notice it failing and how it changes later.

Design the contract
  • • Require object-level authorization on every id-bearing operation, enforced in the data access path (relationship in the WHERE clause), not in per-handler memory.
  • • Document the required permission per operation in the contract, expressed as named capabilities; add `permissions` hints on resources for UI consumers.
  • • Set the 403/404 policy once — 404 outside the relationship boundary, 403 with machine-readable code inside — and lint handlers against it.
  • • Deny by default: a new endpoint with no declared permission rule should fail closed in review, because the default that ships is the default forever.
Observe in production
  • • Per-caller 404/403 rates on id-bearing endpoints: enumeration reads as one identity harvesting hundreds of misses at ordinary request rates.
  • • Audit-log every denial with caller, operation and object id; during incident response, "what did this token *try*" matters as much as what it did.
  • • Test object-level access continuously: automated probes with two tenants' credentials against each other's ids catch the missing WHERE clause before an attacker does.
Evolve without breaking
  • • New roles and permission levels slot between existing ones only if operations reference named capabilities — re-mapping a capability is additive, renaming role checks in every handler is a migration.
  • • Tightening access (member → admin required) breaks integrations that legally depended on the looser rule; stage it with denial telemetry and notice, like any contract change.
  • • Loosening access is additive but irreversible in practice — consumers build on it immediately, so treat a relaxation with the same review as a new operation.
What it costs
  • • Object-level checks in every lookup cost a join or an extra predicate per request — negligible against the breach they prevent, and never actually the bottleneck teams fear.
  • • Uniform 404-for-foreign-ids frustrates legitimate debugging: integrators with mis-scoped tokens see "missing" resources and file tickets; the privacy is worth the support cost, but the cost is real.
  • • Documented per-operation permissions are one more artifact that drifts from enforcement unless generated or tested from the same source — undocumented is bad, wrongly documented is worse.

Misconceptions

Claim
“Our ids are UUIDs — nobody can guess them, so object checks are belt-and-suspenders.”
Reality
Ids are identifiers, not secrets: they leak through logs, URLs, exports, webhooks, support tickets and referrer headers. Unguessability slows enumeration; it does nothing against an id the attacker already has. The relationship check is the control.
Claim
“Authorization is the gateway's job.”
Reality
A gateway can enforce coarse rules — token validity, scope-to-route mapping. It cannot know whether account A owns invoice 9231; object-level authorization needs the data, so it lives in the service, in the query path. See The Gateway as Policy Boundary for what the gateway can own.
Claim
“403 vs 404 is stylistic.”
Reality
It is an information-disclosure decision. A 403 confirms existence to whoever probes; a 404 conceals it and confuses legitimate integrators. Both are defensible policies — an accidental per-endpoint mixture leaks the existence map while still paying the debugging cost.