Skip to content
Knowledge Hub

BOLA/IDOR: logged in, yet able to read someone else's order Basic

Estimated reading time: 8 min read

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.

From the ID the client sends to the decision to allow reading the object
From the ID the client sends to the decision to allow reading the objectStep 1/4: B sends GET /orders/order-42. The token is valid, but the ID in the URL is untrusted input.User Blogged inAPIordersorder-42 owned by Aorder-42
User B → APIorder-42
Step 1/4

B sends GET /orders/order-42. The token is valid, but the ID in the URL is untrusted input.

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 it
const 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 token
const { 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.

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.

Terminal window
npx --prefix examples ts-node examples/src/bola/bola-demo.ts

The 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.

  1. Create at least two users and two tenants, each with their own objects.
  2. Call the endpoint with the owner’s token: allowed.
  3. 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.
  4. Repeat for list, update, delete, bulk, export and nested routes.
  5. 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.

  1. Every object access by a client-supplied ID goes through a scoped query or policy check, for read, update and delete.
  2. userId, tenantId and roles come from a verified token or session, never from the body or query.
  3. List, nested, bulk, export, download, background jobs, GraphQL and websockets all use the same scope function.
  4. Update/delete include the permission predicate in the statement and check the affected row count.
  5. 404/403 are consistent, and error messages do not reveal existence or ownership.
  6. There are integration tests with at least two tenants and two users for every new endpoint.
  7. Log permission denials with the actor and object ID; alert when one actor is denied en masse (a sign of ID scanning).
  8. Use UUIDs to make IDs harder to guess, but don’t treat that as access control.

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).

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?