Research / Authorization

An identifier is not proof of ownership

The same access-control bug keeps returning under different names. It comes from treating the act of naming an object, or holding an identity, as permission to use it.

Kahu Labs ResearchSeptember 4, 2026Authorization9 min read
The short version

Across separate assessments we keep finding the same failure wearing different clothes. An endpoint returns a record because the caller named it. Another returns a user's data because the caller supplied that user's id. A third lets one account act as another because it accepted an identity claim from the request. Each was reported as its own bug. They are one bug: the system treated an identifier as if it were permission.

An identifier answers which object. Authorization answers whether this caller may act on it. When code lets the first answer stand in for the second, every object with a guessable, leaked, or user-supplied identifier becomes reachable.

The system assumption

Most access-control mistakes are not missing checks. They are checks that answer the wrong question. A handler receives a request for object 42, confirms that object 42 exists, loads it, and returns it. The code is doing exactly what it looks like it should. What it never does is ask whether the person on the other end of the request is entitled to object 42 in particular.

That gap is easy to miss because, in day-to-day use, the identifier and the entitlement travel together. The application only ever shows you the id of a thing you already own, so during development and testing the id is a good proxy for ownership. The proxy holds right up until someone sends an id you did not hand them.

  request: GET /api/orders/{order_id}

  what the code checks          what it should also check
  --------------------          ------------------------
  does {order_id} exist?   -->  does the authenticated caller
  load it, return it            own {order_id}? if not, 404/403
The existence check and the entitlement check are different questions. Only one of them was asked.

Where the assumption breaks

It breaks the moment an identifier reaches a caller who should not have it, and identifiers reach everyone. They appear in URLs, in API responses next to the fields you meant to expose, in image and file links, in analytics events, in emails, in the tokens a single-page app decodes in the browser. We have recovered valid user identifiers from static assets on a CDN and from claims baked into JavaScript that anyone can read. Nothing was breached to get them. They were simply lying around, because an identifier is not secret and was never meant to be.

Three variants show up repeatedly:

  1. The sequential or guessable key. Object ids that increment. Change 42 to 43 and read the next record. This is the textbook case and the least common in practice, because most teams have learned to avoid it.
  2. The "unguessable" key used as a secret. A random-looking or encoded id, exposed all over the product, that the endpoint nonetheless treats as if holding it proves something. In one assessment an encoded profile id was returned in image URLs, in list responses, and on public assets, and the profile API handed back any profile to an unauthenticated caller who presented that id. The id looked opaque, so it was trusted. Opaque is not the same as authorized, and an id you print in a dozen places is not a credential.
  3. The identity claim on the request. The subtlest one. Instead of deriving the acting user from the authenticated session, the endpoint reads a user id, an org id, or a subject field out of the request and acts on it. Any valid session can then name any target. We have seen a profile endpoint return any user's data by changing a subject value, and a state-changing endpoint mint an authentication token for another user because it trusted the identifier in the request body rather than the session behind it.

Reconstructing it

Here is the third variant in synthetic form. Two accounts exist. Mallory is a legitimate, fully authenticated user in one tenant. Her target is a user in a different tenant she has no relationship with.

  # Mallory is authenticated as herself. She changes only the
  # identifier in the request, not her own credentials.

  GET /api/profile?user_id=USR_TARGET   HTTP/1.1
  Host: app.example.test
  Authorization: Bearer <mallory-own-valid-session>

  HTTP/1.1 200 OK
  { "user_id": "USR_TARGET",
    "email":   "target@example.com",
    "phone":   "+1-555-0100",
    "org":     "ANOTHER-TENANT" }
The session is genuine and belongs to Mallory. The user_id belongs to someone else. The server never compared the two.

The request is not malformed. It carries a valid session. Nothing about it is an "attack payload" a filter could catch. The only unusual thing is the pairing: a real identity, a foreign identifier, and a server that acted on the identifier without checking it against the identity.

Why the obvious defenses didn't solve it

Teams reach for three defenses here, and none of them closes the gap, because none of them is about entitlement.

Making ids unguessable. Switching from sequential integers to random identifiers raises the cost of blind enumeration. It does nothing once the id leaks, and ids leak by design. Treating a UUID as a capability means every place that displays it becomes a place that grants it.

Authentication. Requiring a valid login proves the caller is someone. It says nothing about which objects that someone may touch. Every example above happened with a perfectly valid session. Authentication without object-level authorization is a locked front door to a building with no locks on the individual rooms.

Input validation. Validators confirm the identifier is well-formed: a real UUID, a number in range, a string matching a pattern. A foreign user's id is perfectly well-formed. Validation checks the shape of the request, not the relationship behind it.

Root cause

The root cause is that authorization was written as a property of the object ("does this exist and is it valid?") instead of a property of the relationship ("may this principal do this to this object?"). The acting principal must come from the trusted context, which is the authenticated session, and never from a value the caller can set. Every object the request touches must be checked against that principal, at the point of access, on the server.

Say it as an invariant: the answer to "who is acting" comes from the session; the answer to "what are they acting on" comes from the request; and the server's job is to prove the first is entitled to the second before doing anything. A bug in this class is always the same shape. Somewhere, the "who" was read from the request instead of the session, or the "may they" step was skipped because the object existed and that felt like enough.

How to test for this class of failure

You cannot find these with a scanner that fires one request at a time, because the bug only appears when you hold two identities and cross them. The method is to test with state and identity:

  • Create at least two accounts, ideally in two tenants, and note each account's object identifiers.
  • Authenticated as account A, replay A's requests but substitute B's identifiers, one field at a time, in the path, query, body, and any decoded token.
  • Watch for a 200 where you expected a 403 or a 404. Read the body: returning B's data to A is the finding, even if the status code looks innocent.
  • Do the same for write and state-changing endpoints, not just reads. The highest-impact cases we find are actions, not disclosures: updating another account's settings, adding a comment in another tenant, issuing a token for another user.
  • Include unauthenticated replays. Some endpoints skip the check entirely, and the "secret" id is the only thing standing between an anonymous request and the data.

How to design the control correctly

Make the ownership check impossible to forget rather than easy to remember.

  • Derive the principal from the session, always. The acting user, org, or tenant is read from the verified session context. A user id in the request is data to be checked against that context, never the source of truth for who is acting.
  • Scope queries by owner at the data layer. Fetch WHERE id = :id AND owner_id = :session_owner in one step, so a foreign id simply returns nothing. A record that is loaded first and authorized second is a record that can be returned before the check runs.
  • Centralize the decision. Route object access through one authorization function that takes (principal, action, object) and is called on every path. Scattered per-handler checks are per-handler omissions waiting to happen.
  • Fail closed and identically. Not-owned and not-found should be indistinguishable to the caller, so the API does not become an oracle for which ids exist.
  • Cover writes and side effects. Every endpoint that changes state re-runs the check. Ownership at read time does not imply ownership at write time.

What we took away from it

The reason this bug is worth a whole note, rather than a line in a checklist, is that its many names hide its single shape. IDOR, broken object-level authorization, horizontal privilege escalation, cross-tenant access, insecure direct reference: reported separately, they read like a scatter of unrelated issues. Named correctly, they are one design error, and one design principle closes all of them. An identifier tells you which door. It is not the key.

It also changed how we test. Because the failure only exists in the relationship between two identities, we treat multi-identity, stateful testing as the default for anything that touches user or tenant data, not as an add-on. A finding in this class is rarely a missing feature. It is a check that answered the wrong question, confidently, on every request.

References

  1. OWASP API Security Top 10 (2023): API1 Broken Object Level Authorization
  2. CWE-639: Authorization Bypass Through User-Controlled Key
  3. CWE-284: Improper Access Control
  4. NIST SP 800-53 Rev. 5, AC-3 Access Enforcement

Internal evidence: Kahu Labs Research, anonymized authorized assessments (2025). Client, target and identifying details are withheld; see our research policy.