The most common authorization mistake I see is treating authentication as authorization. An API verifies you're logged in, then hands you whatever resource ID you requested. The code checks who you are but never asks whether this thing belongs to you.
This breaks down at the object level. A user can retrieve their own invoice at GET /api/invoices/4021, so they try GET /api/invoices/4022 and receive someone else's data. The session token was valid. The resource exists. No one checked ownership.
Why It Hides in Plain Sight
Normal use never exposes this flaw. Users click links and buttons that only reference their own resources. The frontend filters lists and hides IDs that don't belong to them. Code review misses it because the authorization logic lives in a different layer than the route handler, or because the reviewer assumes the ORM or middleware handles it. Integration tests pass because they use a single test account that owns all the fixture data. The API works perfectly until someone changes a number in the URL.
The Method
- Create two accounts with different privilege levels or organizational boundaries. I use one standard user and one from a separate tenant or team if the app has that concept.
- Perform a legitimate action with the first account and capture the request. Note every identifier in the path, query string, and body. This includes obvious IDs and less obvious ones like file keys or reference tokens.
- Replay that exact request using the second account's session token. Change nothing else. If it succeeds, the API is not checking object ownership.
- Test every HTTP verb on that endpoint. A
GETmight be locked down whileDELETEorPATCHis wide open. Different verbs often route through different handlers with different authorization logic. - Enumerate adjacent IDs. Try sequential integers, decrement by one, increment by 100. Try common patterns like switching the last digit or flipping between odd and even. The goal is to confirm the scope of exposure.
- Check for indirect references. If the API uses UUIDs or opaque tokens, look for endpoints that list resources or leak identifiers in error messages, webhooks, or email notifications.
- Test boundary cases. Try accessing resources that are soft deleted, in draft status, or marked private. These states often bypass the normal authorization path.
The UUID Trap
Why This Survives in Production
Authorization is boring and repetitive. Every endpoint needs it, but it's not a feature anyone demos. Frameworks provide authentication middleware out of the box, but object level checks are custom business logic. Developers write the happy path, QA tests the happy path, and no one thinks to test cross account access until it's reported. The code works, the tests pass, and the vulnerability ships.
How I Defend Against It
I enforce authorization at the data access layer, not the route layer. Every query that retrieves a resource by ID also filters by the authenticated user's ownership or role. I make it structurally difficult to forget.
// Enforce ownership in the data layer
async function getInvoice(invoiceId, userId) {
const invoice = await db.invoices.findOne({
id: invoiceId,
userId: userId // Always scope by owner
});
if (!invoice) {
throw new NotFoundError(); // Don't leak existence
}
return invoice;
}
Broken object level authorization persists because it requires discipline at every endpoint. The fix is simple but must be applied everywhere, and everywhere is the hard part.