BOLA/IDOR: logged in, yet able to read someone else's order Basic
Estimated reading time: 8 min read
The problem
Section titled “The problem”User A opens /orders/order-42 and sees their order. User B is logged in, obtains order-42 from a link or an email, and calls the same endpoint. The API only checks that the token is valid and runs SELECT * FROM orders WHERE id = $1. A’s order is returned to B.
Authentication is not wrong: B is a real user. The question that was skipped is does B have rights to this specific object, order-42?
OWASP calls this class of bug BOLA (Broken Object Level Authorization, API1:2023, top of the API Security Top 10). IDOR is the older name, often used for the same idea.
order-42B sends GET /orders/order-42. The token is valid, but the ID in the URL is untrusted input.
The buggy version and the fix
Section titled “The buggy version and the fix”The difference is where the filter conditions come from. tenant_id and owner_id must come from the identity authenticated on the server, not from the query string or body.
// WRONG: having the ID is enough to read itconst order = await db.query('SELECT * FROM orders WHERE id = $1', [id]);return order.rows[0];// RIGHT: scope by actor in the query itself; actor comes from the verified tokenconst { rows } = await db.query( 'SELECT * FROM orders WHERE id = $1 AND tenant_id = $2 AND owner_id = $3', [id, actor.tenantId, actor.userId],);if (rows.length === 0) throw new NotFound(); // don't distinguish "does not exist" from "not yours"return rows[0];For update/delete, put the permission predicate into the statement itself and check the number of affected rows:
UPDATE orders SET status = 'cancelled'WHERE id = $1 AND tenant_id = $2 AND owner_id = $3 AND status = 'pending'RETURNING id;Putting the check and the operation in the same statement also eliminates races like “owner checked, owner changes, then the write happens”.
BOLA, BFLA, BOPLA: three classes of authorization bugs
Section titled “BOLA, BFLA, BOPLA: three classes of authorization bugs”All three belong to the “authenticated but exceeding permissions” family; they differ in the level of the missing check. Numbering follows the OWASP API Security Top 10 2023:
| Code | Name | Question that was skipped | Example |
|---|---|---|---|
| API1 | BOLA: object level | Does the caller have rights to this object? | User B reads A’s /orders/order-42 |
| API3 | BOPLA: object property level | May the caller read/write this property of the object? | A regular user sends PATCH {"role":"admin"} (mass assignment), or the response leaks an extra costPrice |
| API5 | BFLA: function level | May the caller invoke this action? | A regular user calls DELETE /admin/users/7 |
How to remember: BFLA asks “may you use this function”, BOLA asks “may you touch this record”, BOPLA asks “may you see or change this field”. This article focuses on BOLA; fix BOPLA with DTOs/field allowlists for both input and output, and fix BFLA with per-route role/permission checks.
The allow decision: subject, action, object
Section titled “The allow decision: subject, action, object”Authentication answers “who are you”. Authorization answers “may you perform this action on this object”. For every ID from the client, the server needs the subject, action, object and context (tenant, object state).
The policy in the demo (same tenant and owner) is only an example. A real system might let support staff view orders within a tenant through an audited role, or let recipients see part of the data. Write the policy explicitly per business rules and test it for each role.
Two ways to implement it:
| Approach | When | Note |
|---|---|---|
Scope the query (WHERE ... AND owner_id) |
Default for “who does it belong to” policies | Out-of-scope data never leaves the database |
| Load, then call a policy | Complex policies (roles, sharing, delegation) | Don’t serialize, log, cache or send downstream before the check |
404 or 403: return 404 when you don’t want to reveal that the object exists; 403 when the caller may know the object exists but may not perform the action. Pick one convention and keep it consistent. Error messages and response times must not distinguish the two cases either.
Endpoints that are easy to forget
Section titled “Endpoints that are easy to forget”BOLA rarely lives in the detail route that got a careful review. It lives in the other paths:
| Endpoint | How the bug happens | How to block it |
|---|---|---|
List GET /orders |
Only detail is scoped; list returns orders for the whole tenant | Scope the list by actor; test with 2 tenants |
Nested /users/A/orders/order-42 |
Checks that A exists but not that the order belongs to A or that the actor may view it |
Join or predicate on both IDs and on the actor |
Bulk DELETE /orders with an array of IDs |
Only checks the first element | Check every ID, or WHERE id = ANY($1) AND owner_id = $2 and compare the row count with the number of IDs |
| Export / download | The CSV or file route skips the main API’s check | Share the scope function; signed URLs limited by object and time |
| Background jobs, GraphQL resolvers, websockets | Take an ID from a message, with no actor | Pass the actor into the job, check in the resolver and handler |
PostgreSQL Row Level Security is an extra layer of protection in the database: policies automatically add conditions to every query. You need to set the identity in the session correctly, especially with connection pools, and test roles and jobs. RLS does not replace defining actions and responses at the API.
Real-world example
Section titled “Real-world example”npx --prefix examples ts-node examples/src/bola/bola-demo.tsThe buggy function unsafeGetOrder(id) returns order-42 to user-b; the correct getOrder(actor, id) returns the order to user-a and not found to user-b.
The demo uses an in-RAM Map, with no HTTP or SQL; it illustrates the permission decision. To prove a real API is protected, you need integration tests through the endpoint.
How to test
Section titled “How to test”- Create at least two users and two tenants, each with their own objects.
- Call the endpoint with the owner’s token: allowed.
- Call the same ID with another user’s token in the same tenant, and with a user from another tenant: no data returned, and the response does not reveal the owner or tenant.
- Repeat for list, update, delete, bulk, export and nested routes.
- Code review: look for
findById,findUnique, path params or file IDs coming from the request and trace them to where permissions are checked.
Testing through the real endpoint beats unit-testing the policy, because a new route can forget to call the policy entirely.
Production checklist
Section titled “Production checklist”- Every object access by a client-supplied ID goes through a scoped query or policy check, for read, update and delete.
userId,tenantIdand roles come from a verified token or session, never from the body or query.- List, nested, bulk, export, download, background jobs, GraphQL and websockets all use the same scope function.
- Update/delete include the permission predicate in the statement and check the affected row count.
404/403are consistent, and error messages do not reveal existence or ownership.- There are integration tests with at least two tenants and two users for every new endpoint.
- Log permission denials with the actor and object ID; alert when one actor is denied en masse (a sign of ID scanning).
- Use UUIDs to make IDs harder to guess, but don’t treat that as access control.
Trade-offs
Section titled “Trade-offs”Checking at object level
- Stops logged-in users from crossing over to other users’ or other tenants’ objects.
- Actor predicates are pushed down into the query, so out-of-scope data is never loaded.
- An explicit policy enables testing by role and action.
What needs design
- Policies with many roles, sharing and delegation need a model that is easy to review.
- Scattered query scoping is easy to forget; you need a shared repository or abstraction.
- RLS adds operational cost (session identity, pools, debugging).
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”Self-check: (1) Why is requireLogin not enough for GET /orders/:id? (2) Is PATCH {"role":"admin"} BOLA, BOPLA or BFLA? (3) What does a bulk delete need to check?