Authorisation, not authentication, is where API breaches begin
Most APIs authenticate well and authorise badly. The defects that matter are object-level and function-level access rules scattered across handlers, where no single reviewer can see the whole rule.
The defect distribution is not what teams expect
Ask an engineering team where their API security risk sits and most will describe authentication: token validation, key rotation, session handling. Those controls are usually in reasonable shape, because they are centralised, well-documented and solved by libraries.
The defects that produce incidents sit one layer in. The OWASP API Security Top 10 places broken object-level authorisation first and broken function-level authorisation fifth, with broken object property-level authorisation between them. All three are authorisation failures, and all three share a structural cause: the rule depends on which object is being touched and which operation is requested, so it gets implemented inside individual handlers rather than at a single point.
That structure is what makes the class persistent. Authentication is one decision made in one place, so it can be got right once. Object authorisation is hundreds of decisions made in hundreds of places, so the question is not whether a developer knows the rule but whether the architecture makes omitting it possible.
Object-level authorisation is a data question
Consider GET /invoices/{id}. To decide whether the caller may see this invoice, the service must know which account owns it—which means fetching it. The natural implementation is therefore: fetch, check ownership, return. The natural mistake is: fetch, return.
This is not carelessness. It happens because the check lives after the data access, in application code, and because the failure is invisible in testing: the developer’s own account owns the test fixtures, so the positive path passes and there is no negative path unless someone wrote one.
Three structural patterns reduce it materially:
- Scope the query, do not filter the result.
SELECT ... WHERE id = ? AND account_id = ?cannot return another tenant’s record. A post-fetch check can be forgotten; a parameterised tenant predicate in the data layer cannot be, if the data layer refuses queries without it. - Make the ownership dimension non-optional in the data access layer. A repository method that requires an authorisation context as an argument turns omission into a compile-time or startup error rather than a runtime one.
- Do not accept identifiers as authority. Enumerable integers make the defect trivially exploitable, but opaque identifiers only raise the cost of discovery. Unguessable references are a useful additional measure and a poor primary control, a point OWASP’s authorisation guidance makes directly.
The property-level variant deserves separate attention. An endpoint may correctly decide that this user may read this record and still return fields the user should not see, or accept fields the user should not be able to set. Mass assignment from a request body into a model is the usual mechanism. Explicit input and output schemas per role are tedious and they are the fix.
Function-level rules fail quietly in admin paths
Broken function-level authorisation typically shows up where an interface was built for internal use and later exposed, or where an administrative capability shares a code path with a user-facing one distinguished only by a parameter.
The characteristic signature is a route that is protected by being undocumented. It does not appear in the public specification, so it is not in the test matrix, so nobody notices that its only check is a valid session. Discovery is not difficult: client bundles, error messages and predictable naming conventions all leak route structure.
The practical countermeasure is to make the specification the source of truth and deny by default. Every route that the generated specification does not describe with an explicit required permission is refused by the framework at startup. This turns “forgot to add the check” into a deployment failure rather than a latent exposure.
Move the decision to one place
The goal is one place that can answer a single question: may this subject perform this action on this resource in this context? Handlers ask; they do not decide.
That decision point needs the subject’s verified attributes, the action, the resource identity and enough resource metadata to evaluate ownership and state. It returns an allow or a deny with a reason, and it logs the decision. This is the arrangement NIST’s zero trust architecture describes as a policy decision point separated from enforcement, and its principal benefit here is reviewability: the complete authorisation model becomes a policy artefact someone can read, rather than behaviour distributed across a codebase.
A caution worth stating. Centralising the decision does not mean centralising it in a network component. The decision must sit where the resource metadata is available, which for object-level rules is inside the service. A shared library, a policy engine embedded in the service, or a data-layer guard all qualify. A gateway several hops away does not, because it cannot know who owns invoice 4471.
What a token can and cannot tell you
Tokens carry claims about the subject and, in OAuth deployments, about granted scope. They do not carry the ownership relation between that subject and an arbitrary object, and treating a scope as if it did is a recurring error: invoices:read says the client may read invoices, not that it may read this invoice.
Two token-level measures still matter, both covered by OAuth 2.0 security best current practice: audience restriction, so a token accepted by one service is useless at another, and sender constraint, so a stolen token cannot be replayed from elsewhere. DPoP provides the latter for deployments where mutual TLS is impractical.
These reduce the consequence of token theft. They do nothing about the authorisation model, and a team that has implemented both and left object checks in handlers has hardened the wrong layer.
Test the matrix, not the endpoint
Per-endpoint security tests miss the class because the interesting cases are combinations. Generate the matrix instead: for each role, for each resource owned by another principal, for each operation, assert the expected outcome.
This is mechanical enough to automate from the specification, and it has a useful side effect—writing the matrix forces someone to state the intended rule for combinations nobody had considered. A test suite that contains “analyst, other tenant’s export, DELETE, expect 404” is also documentation of the decision.
Include state in the matrix where it matters: a record in a closed period, a user whose access was revoked mid-session, an object shared then unshared. Authorisation defects cluster around state transitions, because the rule was written for the steady state.
The errors you return are part of the boundary
A correctly denied request can still leak. Returning 403 where the object exists and 404 where it does not tells an unauthorised caller which identifiers are real, which converts a blocked attempt into a working enumeration oracle.
For resources outside the caller’s authorisation scope, return the same response whether or not the object exists, and keep the body free of object metadata. Do the same for error detail: a validation message that echoes a field value from a record the caller cannot read is a disclosure, however precise its intent.
Log the full decision internally—subject, action, resource, policy, reason—because that record is what makes an investigation possible. The asymmetry is deliberate: the caller learns that the request was refused, and the operator learns why.