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.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
"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.
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 roleThe 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).
1def get_invoice(req, id):2 require_authenticated(req) # who are you? ✓3 require_scope(req, "invoices:read") # may you use4 # this endpoint? ✓5 return db.invoices.get(id) # may you see THIS6 # invoice? …never asked7 8# attacker with any valid account:9# for i in range(9000, 9999): GET /invoices/{i}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 = :acct7 if inv is None:8 return 404 # absent and forbidden9 # are indistinguishable10 return invThe 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.
- 1Team → middleware: implements authn plus per-endpooint scope checks and calls authorization done.
- 2Handlers → database: every
GET /things/{id}fetches by id alone; the caller's relationship to the row is never consulted. - 3Attacker → API: signs up for a free account, enumerates sequential ids across invoices, orders and exports.
- 4Data → attacker: thousands of foreign records read at normal request rates — no error spike, no auth failures, nothing anomalous to alert on.
- 5Provider → disclosure: the breach surfaces via a researcher or a customer; the fix is one WHERE clause, the incident is a regulatory filing.
- 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.
- • 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.
- • 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.
- • 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.
- • 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.