BOLA vulnerability in Node APIs: how to fix IDOR and catch it in your logs
How broken object level authorization gets into Node code, how to fix it in the query, and how probing shows in logs.

The email arrives on a Friday: a customer changed /invoices/1041 to /invoices/1042 in their browser and got someone else’s invoice. That is a BOLA vulnerability, the API bug scanners and bored attackers try first on every API they find.
The short answer: a BOLA vulnerability happens when an endpoint loads an object by the ID the client sent without checking that the caller may access it. The fix is to put the ownership or tenant condition into the database query itself, return 404 for objects the caller cannot see, and add a cross-tenant test for every route that takes an ID. Then watch your logs for callers touching many distinct IDs.
What is a BOLA vulnerability?
BOLA (broken object level authorization) is the API name for IDOR (insecure direct object reference). OWASP ranks it API1:2023, first in the current API Security Top 10. Authentication is usually fine in these cases. The handler checks “who are you” and forgets “is this yours”.
// Express 5. Authenticated, and still broken.
app.get('/v1/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoice.findUnique({ where: { id: req.params.id } });
if (!invoice) return res.status(404).json({ error: 'not_found' });
res.json(invoice);
});
The same mistake in other shapes:
- The list endpoint filters by tenant, but detail, update and delete do not.
- The ID comes from the body (
{ "accountId": 77 }in a PATCH) instead of the path. - A nested route checks the parent but loads the child by its own ID:
/projects/:projectId/files/:fileId. - A bulk endpoint checks only the first ID in an array.
- A new API version or admin route skips the middleware the others use. Route rewrites during an Express 5 migration are a common moment for this.
Why scanners find IDOR so easily
BOLA is cheap to test. Sequential IDs make enumeration trivial, success is a 200 where a 404 belongs, and the payoff is real data. Two accounts and a script test every route that takes an ID. UUIDs make guessing harder, but IDs leak through URLs, exports, emails and other responses, so random IDs are a speed bump, not an authorization check.
How to fix broken object level authorization in Node.js
Step 1: scope the query
Put the ownership condition in the query, so the object cannot load without it:
app.get('/v1/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoice.findFirst({
where: { id: req.params.id, accountId: req.user.accountId },
});
if (!invoice) return res.status(404).json({ error: 'not_found' });
res.json(invoice);
});
Step 2: make the scoped path the only path
A small repository layer that requires a scope removes the chance to forget:
// invoices.repo.js
export function invoicesFor(scope) {
if (!scope?.accountId) throw new Error('invoicesFor requires an account scope');
const base = { accountId: scope.accountId };
return {
get: (id) => db.invoice.findFirst({ where: { ...base, id } }),
list: (opts = {}) => db.invoice.findMany({ where: base, ...opts }),
update: (id, data) => db.invoice.updateMany({ where: { ...base, id }, data }),
};
}
updateMany with the scope in where returns a count. Zero means the object does not exist or is not the caller’s, and you return 404 either way, which avoids confirming the ID exists. Use 403 when the caller can see the object but not perform the action, like a viewer trying to delete.
Step 3: in Fastify, load the resource in a preHandler
import Fastify from 'fastify';
const app = Fastify();
app.decorateRequest('invoice', null);
async function loadInvoice(request, reply) {
const invoice = await invoicesFor(request.user).get(request.params.id);
if (!invoice) return reply.code(404).send({ error: 'not_found' });
request.invoice = invoice;
}
app.get('/v1/invoices/:id', { preHandler: [authenticate, loadInvoice] },
async (request) => request.invoice);
In NestJS the same idea fits a guard, but the scoped query is what makes it hold.
Step 4: test it like an attacker
Single-user unit tests rarely catch BOLA. Add a cross-tenant test per route:
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('account B cannot read account A invoice', async () => {
const a = await createAccountWithInvoice();
const b = await createAccount();
const res = await fetch(`${BASE}/v1/invoices/${a.invoiceId}`, {
headers: { authorization: `Bearer ${b.token}` },
});
assert.equal(res.status, 404);
});
Generate the route list from your router so a new route without a cross-tenant test fails CI.
How to detect BOLA attacks in request logs
Probing has a recognizable shape:
- One caller, many distinct object IDs on one route pattern.
- Sequential IDs in order from the same token.
- A high share of
403or404responses on/:idroutes for one caller. - Mostly
404s, then a run of200s. A scanner found a route without a check. This is the one to alert on. - IDs requested that never appeared in that caller’s list responses.
Your logs need caller identity and route pattern, not just the raw URL. A quick offline check over exported JSON lines:
// enum-check.mjs: node enum-check.mjs requests.jsonl
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
const byCaller = new Map();
const rl = createInterface({ input: createReadStream(process.argv[2]) });
for await (const line of rl) {
const r = JSON.parse(line); // { userId, route, objectId, status }
if (!r.objectId) continue;
const key = `${r.userId} ${r.route}`;
const s = byCaller.get(key) ?? { ids: new Set(), denied: 0, ok: 0 };
s.ids.add(r.objectId);
if (r.status === 403 || r.status === 404) s.denied++;
else if (r.status < 300) s.ok++;
byCaller.set(key, s);
}
for (const [key, s] of byCaller) {
if (s.ids.size > 50) console.log(key, { distinct: s.ids.size, denied: s.denied, ok: s.ok });
}
Tune the threshold. Admin users who touch thousands of records need an exception, which is a good reason to tag callers by role. The same distinct-accounts idea powers credential stuffing detection.
For a one-person team, the afternoon version is scoping every /:id query and running this script weekly. A ten-person team should add the repository layer, generated cross-tenant tests in CI and an alert on pattern 4.
BOLA checklist for every route that takes an ID
If you only do one thing, grep for findUnique, findById and WHERE id = calls that do not include a tenant or owner condition, and fix those first. Then confirm:
- The ownership condition is in the query, not an
ifafter it. - Body and query-string IDs get the same check as path IDs.
- Nested resources check the child.
- Bulk endpoints check every ID.
- Invisible objects return
404. - A cross-tenant test exists.
- Logs record caller, route pattern and object ID.
FAQ
What is the difference between BOLA and IDOR?
They describe the same flaw. IDOR is the older web application term, and BOLA is the name OWASP uses in the API Security Top 10, where it is API1:2023.
Do UUIDs prevent IDOR vulnerabilities?
No. They make IDs harder to guess, but IDs leak through links, logs and other responses. Authorization still has to be checked on every request.
Should an unauthorized object return 403 or 404?
Return 404 when the caller should not know the object exists, which is the usual case across tenants. Return 403 when they can see it but lack permission for the action.
Doing this with ApiTraffic
If you would rather not build enumeration detection yourself, ApiTraffic covers the logging and the response.
- Requests are recorded under the matched route pattern (for example
/api/v1/invoices/{id}), andapitraffic.tag('userId', String(req.user.id))ties each one to a user. Tags stick even when a guard rejects the request. - Review one caller with
tag:userId == "1042" AND status_code >= 400. - Behavioral protection scores each caller on distinct object IDs per endpoint, 401/403 counts, 404 counts and distinct endpoints, and gives callers over the threshold a temporary deny on every server. Monitor mode shows who would be denied first.
Start free and see every request your API handles: apitraffic.io
Sources: OWASP API Security Top 10 2023, API1:2023 Broken Object Level Authorization; OWASP Authorization Cheat Sheet
Keep reading
CORS misconfiguration in Express and Fastify: how to find it and test for it
One line added to silence a frontend error, cors({ origin: true, credentials: true }), lets any website read your users' data. Find CORS misconfiguration in Express and Fastify APIs, fix it, and test it in CI.
Credential stuffing detection for Node APIs: spotting it in your login logs
Every request looks like a normal login, which is why stuffing goes unseen until a customer reports a login they did not make. Here is how to see it in your logs.
API key security for Node APIs: prefixes, hashing, scopes and rotation
A customer just pushed their API key to a public repo. Can you tell if anyone used it, and could a database leak expose every key? Design API key security in Node properly: prefixes, hashing, scopes and rotation.