October 20, 2026
Broken Object-Level Authorization: The API Flaw Hiding Behind a Changed ID
How BOLA vulnerabilities expose customer data, why ordinary functional tests miss them, and how engineering teams can detect and preventβ¦
By Objectplayer
6 min read
How BOLA vulnerabilities expose customer data, why ordinary functional tests miss them, and how engineering teams can detect and prevent them.
Modern applications expose objects everywhere: user profiles, invoices, projects, messages, wallets, support tickets, API keys, and administrative records. APIs commonly identify these resources with a value in a URL or request body:
GET /api/invoices/8421GET /api/invoices/8421That identifier tells the server which invoice the client wants. It does not prove that the current user is allowed to see it.
When an application retrieves or modifies an object without verifying that the authenticated user is authorized for that specific object and action, it has a Broken Object-Level Authorization, or BOLA, vulnerability. The same weakness is often called an Insecure Direct Object Reference, or IDOR.
BOLA is conceptually simple, but its consequences can be severe: cross-customer data exposure, unauthorized changes, account takeover paths, privacy incidents, and large-scale scraping. It also survives many ordinary tests because the intended workflow works perfectly for the intended user.
This article explains how the flaw appears, how teams can detect it safely, and how to prevent it at the design and implementation levels.
Authentication is not authorization
Authentication answers:
Who is making this request?
Authorization answers:
Is this identity permitted to perform this action on this specific resource?
An endpoint can require a valid login and still be vulnerable. Consider this simplified handler:
app.get("/api/invoices/:invoiceId", requireLogin, async (req, res) => {
const invoice = await db.invoices.findById(req.params.invoiceId);
res.json(invoice);
});app.get("/api/invoices/:invoiceId", requireLogin, async (req, res) => {
const invoice = await db.invoices.findById(req.params.invoiceId);
res.json(invoice);
});The middleware confirms that the caller is logged in. The query then fetches whichever invoice ID appears in the URL. Nothing connects the invoice to the current user or tenant.
If Alice owns invoice 8421 and Bob owns invoice 8422, Alice may be able to request /api/invoices/8422. A random identifier or UUID makes guessing harder, but it does not create an authorization decision. Identifiers can leak through browser history, logs, analytics, referrer headers, exported files, support messages, or related API responses.
Where BOLA appears
The weakness is not limited to GET requests. It can affect every operation on an object:
GET /api/projects/123
PATCH /api/projects/123
DELETE /api/projects/123
POST /api/projects/123/members
POST /api/orders/123/refund
GET /api/users/123/api-keysGET /api/projects/123
PATCH /api/projects/123
DELETE /api/projects/123
POST /api/projects/123/members
POST /api/orders/123/refund
GET /api/users/123/api-keysCommon warning signs include:
- The client supplies userId, accountId, tenantId, or organizationId.
- The frontend hides a button but the server accepts the underlying request.
- A database record is loaded by its primary key alone.
- Different roles use the same endpoint without a centralized policy.
- Batch, export, search, or GraphQL operations return objects from multiple owners.
- Child objects are checked without verifying their parent organization.
- A background job trusts object references placed into a queue.
- Mobile and legacy API versions enforce different rules.
Multi-tenant applications deserve particular attention. A missing tenant condition can turn one incorrect query into access across many customers.
A realistic failure pattern
Suppose a project-management API receives this request:
PATCH /api/tasks/4f98
Authorization: Bearer <alice-token>
Content-Type: application/json
{
"status": "complete"
}PATCH /api/tasks/4f98
Authorization: Bearer <alice-token>
Content-Type: application/json
{
"status": "complete"
}The server uses the authenticated identity but updates by task ID alone:
UPDATE tasks
SET status = 'complete'
WHERE id = '4f98';UPDATE tasks
SET status = 'complete'
WHERE id = '4f98';If task 4f98 belongs to Bob's organization, Alice has changed another tenant's data. A safer operation constrains the update to the caller's authorized tenant or uses an authorization policy before changing state:
UPDATE tasks
SET status = 'complete'
WHERE id = '4f98'
AND organization_id = :authorized_organization_id;UPDATE tasks
SET status = 'complete'
WHERE id = '4f98'
AND organization_id = :authorized_organization_id;The application must also distinguish actions. A user allowed to read a task may not be allowed to edit it, assign it, delete it, or view its audit history.
Why functional testing misses it
A normal test often follows this path:
- Create a task as Alice.
- Retrieve Alice's task.
- Update Alice's task.
- Confirm the change.
Every result is correct. The missing test is the negative, cross-principal case:
- Create a task as Bob.
- Attempt to retrieve it as Alice.
- Attempt to modify it as Alice.
- Confirm that both operations are denied and that no state changed.
Authorization quality lives in these negative cases. A feature is not secure merely because permitted users can use it. It must reliably reject users who are authenticated but not permitted.
How to detect BOLA safely
Detection should be systematic and performed in an authorized test environment whenever possible.
1. Build an actor-and-object matrix
List the important actors:
- Anonymous user
- Normal user
- User from another tenant
- Team manager
- Support operator
- Administrator
- Service account
Then list sensitive objects and actions:
- Profile: read, edit, export
- Invoice: read, download, refund
- Project: read, edit, invite, delete
- API key: list, create, revoke
- Administrative record: read, approve, audit
For each intersection, define whether access should be allowed. This turns an implicit assumption into a testable policy.
2. Use at least two controlled identities
Create two users in separate accounts or tenants. Generate one object under each identity. Capture an authorized request for User A, then substitute only the object reference with User B's controlled test object.
Test read and write operations independently. A system may correctly deny reads while still allowing updates or deletions.
Do not access real users' objects, enumerate production identifiers, or collect unnecessary data. A controlled cross-account test is usually enough to demonstrate the flaw.
3. Examine every location carrying identity
Object references can appear in:
- URL paths
- Query parameters
- JSON and form bodies
- GraphQL variables
- Headers
- File names
- WebSocket messages
- Nested arrays and batch requests
Also test parent-child relationships. An endpoint such as /organizations/A/projects/7 may validate project 7 but fail to confirm that it belongs to organization A.
4. Test role and action boundaries
Replace a high-privilege token with a lower-privilege token while keeping the request unchanged. Attempt actions adjacent to the permitted one:
- Read versus update
- Update versus delete
- View versus export
- Manage content versus manage members
- Issue refund versus view payment
A role label alone is rarely sufficient. Authorization should consider the actor, action, resource, ownership or tenant, and sometimes resource state.
5. Add automated negative tests
For every sensitive endpoint, include tests that expect denial:
it("does not allow a user to read another tenant's invoice", async () => {
const response = await request(app)
.get(`/api/invoices/${tenantBInvoice.id}`)
.set("Authorization", `Bearer ${tenantAToken}`);
expect(response.status).toBe(404);
});it("does not allow a user to read another tenant's invoice", async () => {
const response = await request(app)
.get(`/api/invoices/${tenantBInvoice.id}`)
.set("Authorization", `Bearer ${tenantAToken}`);
expect(response.status).toBe(404);
});Depending on the application, returning 404 can avoid confirming that an unauthorized object exists. The crucial requirement is consistent denial with no sensitive response and no state change.
How to prevent BOLA
Derive ownership from trusted server-side context
Do not trust a caller-supplied userId or tenantId when the server already knows the authenticated identity and active tenant.
Risky:
const invoices = await db.invoices.findAll({
where: { userId: req.query.userId }
});const invoices = await db.invoices.findAll({
where: { userId: req.query.userId }
});Safer:
const invoices = await db.invoices.findAll({
where: {
userId: req.user.id,
tenantId: req.authenticatedTenant.id
}
});const invoices = await db.invoices.findAll({
where: {
userId: req.user.id,
tenantId: req.authenticatedTenant.id
}
});Scope data access, not only controllers
The safest query is often one that cannot return an unauthorized object:
const invoice = await db.invoices.findOne({
where: {
id: req.params.invoiceId,
tenantId: req.authenticatedTenant.id
}
});const invoice = await db.invoices.findOne({
where: {
id: req.params.invoiceId,
tenantId: req.authenticatedTenant.id
}
});This is stronger than fetching globally and hoping every caller remembers a separate ownership check. Repository or service-layer methods such as findInvoiceForTenant make the safe path explicit.
Centralize authorization policy
Scattered if statements become inconsistent as an application grows. Use a policy layer or a small set of authorization functions that accept:
- Authenticated actor
- Requested action
- Resource
- Tenant or ownership context
- Relevant resource state
For example:
if (!invoicePolicy.canRead(req.user, invoice)) {
return res.sendStatus(404);
}if (!invoicePolicy.canRead(req.user, invoice)) {
return res.sendStatus(404);
}Keep the policy on the server. Frontend checks improve usability but are not security controls.
Deny by default
New endpoints and actions should begin inaccessible until a policy explicitly permits them. Default-deny behavior reduces the chance that a newly introduced route silently becomes available to every authenticated user.
Use opaque IDs only as defense in depth
UUIDs and random references reduce easy enumeration, which is useful. They do not replace authorization. The server must make the same decision even if an attacker already knows the identifier.
Protect bulk and indirect access paths
Apply the same policy to:
- Search and filtering
- CSV and PDF exports
- Batch updates
- GraphQL resolvers
- Background jobs
- Cached objects
- Signed download links
- Administrative and support tools
A secure detail endpoint does not compensate for an export endpoint that leaks the same records.
Log denials without leaking secrets
Repeated cross-object access attempts can indicate abuse. Record the actor, tenant, action, resource type, outcome, and correlation ID. Avoid logging tokens, full sensitive objects, or unnecessary personal data.
Monitoring is not prevention, but it helps detect exploitation and validate that controls are operating.
A practical engineering checklist
Before releasing an endpoint that handles a sensitive object, verify:
- β The actor is authenticated where required.
- β The server derives user and tenant context from trusted authentication data.
- β Authorization is checked for the exact action and object.
- β The query is scoped to the authorized owner or tenant.
- β Lower-privileged and cross-tenant cases are tested.
- β Read and write paths are tested separately.
- β Bulk, export, nested, and indirect paths apply the same policy.
- β Denials reveal no sensitive data and create no state change.
- β Security-relevant denials are observable without logging secrets.
- β Random identifiers are treated only as defense in depth.
Final thought
BOLA vulnerabilities are not caused by an exotic payload. They emerge when an application confuses knowing an object's identifier with having permission to use it.
The durable fix is not a more complicated identifier. It is a consistent server-side rule:
Every request must prove that this actor may perform this action on this resource in this context.
When that rule is centralized, reflected in data access, and covered by negative tests, an entire class of high-impact API failures becomes much harder to introduce.
About Objectplayer Lab
Objectplayer Lab is a security-led engineering studio helping teams identify risk and build dependable software across Web2, Web3, and AI. Learn more at objectplayer.com.