Object ID checks stop a signed-in user from reading or changing a record they do not own. A valid session proves identity. It does not prove the caller may touch the row, file, or job named in the URL.
Teams that skip this check ship a classic insecure direct object reference. The attacker does not need a stolen password. They change an integer, a UUID, or a slug and the API returns someone else’s invoice, ticket, or upload. That is an authorization bug, not a rate-limit problem and not a webhook problem.
What object checks protect
These checks sit on every path that takes an identifier from the client. That includes GET, write, delete, export, and share endpoints. The same rule applies to background jobs that accept an ID from a queue message the client originally created.
Protect these surfaces first:
- Records tied to a tenant, account, or workspace.
- Child objects such as line items, comments, files, and webhook deliveries.
- Admin tools that accept a customer ID from a form field.
- Export and download URLs that live longer than the page that created them.
If the identifier is guessable, the bug is easier to find. Unpredictable IDs help, but they are not a substitute for an ownership check. UUIDs leak in logs, referrer headers, and support screenshots.
Design choices that matter
Treat object authorization as product behavior. Write the rule next to the handler, not in a forgotten middleware comment.
- Resolve the caller’s tenant from the session or token, then load the object inside that tenant scope. Do not load by ID and compare afterward if a miss would leak timing or error text.
- Return the same not-found response for a missing row and a foreign row. A 403 that only appears for real IDs is an oracle.
- Check the action, not just the owner. A viewer may read a ticket and still must not close it.
- Re-check on the write path. A UI that hid the button is not a control.
- Include the object type in audit logs: actor, tenant, object ID, action, decision.
Prefer a small helper the whole service uses, such as load_invoice_for(caller, id). Copy-pasted SQL in each handler drifts. One helper that always filters by tenant_id is easier to review and easier to test.
Common mistakes
A few patterns show up in almost every incident write-up.
- Trusting the ID in the path because the frontend built it.
- Checking ownership only on GET, then allowing PUT with the same ID.
- Nested routes that check the parent and skip the child, so /orgs/1/files/9 still serves another org’s file if 9 exists.
- GraphQL resolvers that accept a global ID and skip the parent scope.
- Support impersonation that disables checks for everyone with the flag, including a leaked staff token.
- Tests that only cover the happy path with the owner’s own ID.
Safe rollout
Start by listing every route that accepts an object ID. Map each one to an owner column and an action. Add a test that uses two tenants and expects a not-found on the cross-tenant ID. Run that suite in CI before you change production behavior.
Roll the check out endpoint by endpoint. Log would-be denials for a short window if you are unsure about a shared-object path, then enforce. Give support a documented impersonation flow with its own audit trail, not a bypass flag.
Alert when a single token produces a spike of not-found responses on ID routes. That pattern is closer to probing than to a confused client.
What to tell partners
Publish that clients must not assume an ID they can see is an ID they may call. Say that unknown IDs return not-found, and that bulk endpoints reject the whole batch if any ID falls outside the caller scope, or skip those rows and return a per-item status. Pick one and document it.
Object ID checks belong next to authentication, not after it as a polish item. Ship the helper, cover it with a two-tenant test, and treat a cross-tenant hit as a defect worth a postmortem.