API Auth Bugs Are Usually State Bugs

API Auth Bugs Are Usually State Bugs

Most API authorization bugs get described as a missing check, as if someone forgot an if statement. They are almost never that. They are a missing state model: the system never defined, as data it enforces, who owns this object, what tenant it belongs to, what this role may do, or what this token is scoped for. Where that state is undefined, the server falls back to trusting the request, and trusting the request is the bug. Here is the frame, the five places it shows up, and why testing endpoint by endpoint will never find it.

October 13, 2026
Harrison Guo
9 min read
Security Architecture

Most API authorization bugs get written up as a missing check. The report says the endpoint forgot to verify ownership, as if there was one absent if statement and adding it closes the hole. That framing is comforting and it is usually wrong.

The check was not missing because someone forgot a line. It was missing because the thing it was supposed to check was never modeled. The system never represented, as data it actually enforces, that this object belongs to this user, that this record lives in that tenant, that this role is allowed to perform this action, that this token is scoped to these operations. With none of that state defined, the endpoint answers the only question it has an answer to, which is “are you logged in”, and then reads the target straight out of the request and hands it over.

So the slogan for this whole class: most API auth bugs are not missing checks, they are missing state models. Once you see authorization as a question about state, the five places these bugs live stop looking like five different mistakes and start looking like one.

Here is the whole class on one page, which is the rest of this post in a table:

BoundaryThe state never modeledThe bug it becomesThe fix
Object ownershipwho owns this objectBOLA/IDOR: change the id, get another user’s objectevery query scoped to the owner
Tenantwhich tenant a row belongs tocross-tenant read via a request-supplied tenant idtenant bound to the principal, matched on every access
Role and fieldswhich fields a role may writemass assignment: the client sets role to adminthe server owns the writable-field list
Token scope and freshnesswhat a credential may do, and until whena read token writes; logout leaves sessions alivecheck scope per action, revoke on state change
Service identitywho the calling service is“on the internal network” treated as authorizationservices authenticate each other explicitly
Boundary Object ownership
The state never modeled who owns this object
The bug it becomes BOLA/IDOR: change the id, get another user’s object
The fix every query scoped to the owner
Boundary Tenant
The state never modeled which tenant a row belongs to
The bug it becomes cross-tenant read via a request-supplied tenant id
The fix tenant bound to the principal, matched on every access
Boundary Role and fields
The state never modeled which fields a role may write
The bug it becomes mass assignment: the client sets role to admin
The fix the server owns the writable-field list
Boundary Token scope and freshness
The state never modeled what a credential may do, and until when
The bug it becomes a read token writes; logout leaves sessions alive
The fix check scope per action, revoke on state change
Boundary Service identity
The state never modeled who the calling service is
The bug it becomes “on the internal network” treated as authorization
The fix services authenticate each other explicitly

Authentication is who you are; authorization is what you may touch

Keep these apart, because they fail differently.

Authentication is proving identity. Its bugs are forgery and bypass: a password-reset token you can predict, a session that stays valid after logout, a JWT you can re-sign because the server accepts alg=none. These are real and they are the minority of what actually hurts.

Authorization is deciding what a proven identity may do. Its bugs are a real, logged-in, perfectly authenticated user reaching something that is not theirs. No forged token, no bypass. They logged in as themselves and then read your data, because the server confirmed they were someone and never confirmed they were allowed.

The large majority of high-impact API bugs are the second kind. A verified user who can read another company’s records has done direct damage with nothing exotic. That is why the rest of this is about authorization, and why the recurring shape is always the same: a piece of state that should have governed the decision was never there to govern it.

Object ownership: the server knows the id, not the owner

The most common high-impact API bug is object-level: the server returns a record because the request named its id, without confirming the caller owns it.

GET /api/orders/1041      -> your order
GET /api/orders/1042      -> someone else's order, returned all the same

The endpoint authenticated you. Then it took 1042 from the URL, looked it up, and returned it. The missing state is the relationship between the actor and the object: does this user own this order. That relationship either lives in your data and gets consulted on every access, or it does not exist and every id is a public id.

flowchart LR
  req["request names object 1042"]
  authn{"are you logged in?"}
  serve["return object 1042"]
  own{"do you own 1042?"}
  req --> authn
  authn -->|yes| serve
  own -. the check that was never modeled .-> serve

Notice what does not fix this. UUIDs instead of sequential integers make the ids hard to guess, which is not the same as making them private; a leaked or logged UUID is just as usable. The fix is the ownership check, which means the ownership relationship has to be modeled as state the query is forced through: not “fetch order 1042” but “fetch order 1042 where owner is the current principal”. The bug is that the owner was never part of the question.

Tenant boundary: the same bug, one level up

Multi-tenant systems have the same failure at the scale of whole customers. Every row belongs to a tenant, and every query is supposed to be scoped to the caller’s tenant. The bug appears when the tenant id is taken from the request, a URL segment, a header, a body field, a JWT claim the client can influence, instead of being bound to the authenticated principal on the server.

GET /api/report?company=ACME     -> change ACME to a competitor

If the server trusts company from the request, you have crossed a tenant boundary, which is usually the worst thing an API can let you do. The state model that is missing: every object carries a tenant, the authenticated principal is bound to a tenant on the server, and the two must match on every access. When tenant is a request parameter rather than a server-held fact about the caller, the boundary is decorative.

Role and field writes: letting the client set authorization state

Some authorization state is not about reading other people’s things, it is about editing your own authorization. Role is state. Verified status is state. Account tier is state. If the client can set them, the client can authorize itself.

POST /api/users          { "name": "me", "role": "admin" }
POST /api/profile        { "email": "me", "is_verified": true }

This is mass assignment: the server binds request fields straight onto the model, so a field the user was never supposed to control gets written because they included it. The missing state model here is which fields are writable by whom. The server has to own that an ordinary user may write name and may not write role, as an explicit rule, not as an accident of which fields happened to be in the struct. When “what the client may set” is undefined, the client’s answer is “everything”.

Token scope and stale sessions: when the credential and the decision drift apart

A token is a carrier of authorization state: who it represents, what it is scoped to do, when it expires. Two failures come from letting that state drift away from reality.

Scope: a token issued to do one thing is accepted to do anything, because the server checks that the token is valid but not that it is permitted for this action. A read-scoped token that can write is a master key with a narrow label.

Staleness: logout that does not invalidate the session, a password change that leaves old sessions alive, a revoked API key that keeps working until it expires. The user’s real authorization changed and the credential did not get the message. The gap between “what this credential should be allowed to do now” and “what the server still honors” is the bug, and it is a state-synchronization failure, the same shape as a cache that was never invalidated.

Service-to-service: the network is not an identity

The last one hides inside your own backend. Internal services often trust each other because the call came from inside the network, which is an absent state model wearing a firewall. “On the internal network” is not an identity and not an authorization. A request that reaches an internal service through a misrouted proxy, a compromised neighbor, or a webhook or callback that can be aimed inward arrives with all the trust the network confers and none of the identity it should have had to earn. Services need to authenticate and authorize each other as explicitly as you authorize users, because “where the packet came from” was never authorization state in the first place.

Why you cannot find these one endpoint at a time

Here is the practical punchline. The reason these survive code review and endpoint-by-endpoint testing is that the property being violated is not a property of any single endpoint.

“A user can only read objects they own” is an invariant that spans every endpoint taking an object id. Read handlers one at a time and each looks fine on its own; the one whose query forgot the ownership scope looks exactly like the ones that remembered, right up until you call it with a second account’s id and it answers. You do not find authorization bugs by reading more carefully. You find them by stating the invariant and testing it with more than one identity: two accounts, cross-read every id-taking route, change every tenant field, post every field you were not given. The bug is wherever the invariant is not enforced, and the invariant is state, which is the same reason the fix belongs in one modeled place rather than sprinkled as checks across a hundred handlers. If you want the general version of that argument, it is the same one as choosing where a consistency rule is enforced rather than hoping every call site remembers it.

The one sentence to take

Stop reading an API authorization bug as a forgotten check and start reading it as a relationship that was never modeled: who owns this, whose tenant is it, what may this role do, what is this token for, who is this service. Where that state exists and every access is forced through it, the bugs have nowhere to live. Where it does not, the server falls back to trusting the request, and trusting the request is the whole vulnerability.

🎧 More Ways to Consume This Content

I occasionally advise small teams on backend reliability, Go performance, and production AI systems. Learn more: /services

Comments

This space is waiting for your voice.

Comments will be supported shortly. Stay connected for updates!

Preview of future curated comments

This section will display user comments from various platforms like X, Reddit, YouTube, and more. Comments will be curated for quality and relevance.