API Security in Depth · API Threat Landscape & Object-Level Risks
Broken Object Level Authorization (BOLA)
The #1 API risk: checking that the caller may access this specific object.
BOLA (a.k.a. IDOR) happens when an endpoint takes an object ID from the request and fetches it without checking that the caller may access that object. Authentication alone doesn't help — the attacker is a valid user. Fix it structurally: scope queries by owner/tenant (WHERE id = :id AND tenant_id = :tenant), centralize policy checks, and test cross-user access automatically for every endpoint. Random IDs (UUIDs) reduce enumeration but are not authorization.
Ways to enforce object-level access
| Approach | Strength | Weakness |
|---|---|---|
| Check in each handler | Explicit | Easy to forget one endpoint |
| Scoped repository (tenant/owner in every query) | Hard to bypass | Needs discipline in raw queries |
| Policy engine (OPA/Cedar/Oso) | Central, auditable | Integration effort; still must be called |
| Database row-level security | Defense in depth | Requires per-request DB session context |
| Unguessable IDs | Slows enumeration | Not authorization (IDs leak via URLs, logs, shares) |
@router.get("/v1/orders/{order_id}")
async def get_order(order_id: UUID, user: User = Depends(current_user), db=Depends(session)):
order = await db.scalar(
select(Order).where(Order.id == order_id, Order.account_id == user.account_id))
if order is None:
raise HTTPException(404) # same response for "missing" and "not yours"
return OrderOut.model_validate(order)
app.get("/v1/orders/:id", requireAuth, async (req, res) => {
const order = await prisma.order.findFirst({
where: { id: req.params.id, accountId: req.user.accountId },
});
if (!order) return res.status(404).json({ type: "about:blank", status: 404 });
res.json(toOrderOut(order));
});
@GetMapping("/v1/orders/{id}")
@PreAuthorize("@orderAccess.canRead(authentication, #id)")
public OrderOut get(@PathVariable UUID id) {
return orders.findById(id).map(OrderOut::from).orElseThrow(NotFound::new);
}
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (account_id = current_setting('app.account_id')::bigint);
-- per request: SET LOCAL app.account_id = '42';
Common pitfalls
- Fetching by ID then checking ownership only in some code paths.
- Trusting tenant IDs from the request body or path over the token.
- Returning 403 vs 404 differently and revealing existence.
Key points
- Scope every query by owner/tenant
- Automate cross-user tests
- IDs are not secrets
Further reading: OWASP API1:2023 BOLA
Practice questions
Why don't UUIDs fix BOLA?
Answer
They make guessing harder, but IDs leak through URLs, logs, shared links and other responses; access must still be checked against the caller.
How do you test for BOLA systematically?
Answer
Create two users/tenants, have user A create objects, then call every object endpoint as user B with A's IDs and assert 404/403 — ideally generated from the OpenAPI spec in CI.
Open in the app to mark lessons complete, take notes and track your progress.