Three HTTP requests. That is all it takes.
A user logs into your application, gets a valid JWT and calls GET /api/invoices/1042. They see their invoice. Then they change the number: GET /api/invoices/1043. They see someone else's invoice. Then GET /api/invoices/1044. And so on, in a loop, until they have downloaded every invoice in your system.
No brute force. No injection. No exploit kit. Just a number that increments by one.
This is Broken Object Level Authorization (BOLA). It sits at API1:2023 in the OWASP API Security Top 10. The top position, unchanged from 2019. Salt Labs' Q1 2025 State of API Security report, based on a survey of 206 organizations, found BOLA alone accounted for 27% of observed API attacks. And it is almost always the fault of an authenticated endpoint that checks "are you logged in" but never asks "do you own this specific record."
Your middleware handled the first question. Nobody wrote code for the second.
Authentication Is Not Authorization
This is where most BOLA write-ups stop: they say "check that the user owns the object." That advice is correct and useless. The harder question is why so many production APIs skip this check, and the answer is structural.
Modern frameworks make authentication easy. Laravel ships with Sanctum and a ->middleware('auth:sanctum') decorator you can add to any route group in five minutes. FastAPI has Depends(get_current_user) that integrates into every endpoint in two lines. You add it once at the route level and every protected endpoint now verifies the token.
Authorization at the resource level is different. The framework cannot do this for you because the framework does not know your business rules. It does not know that invoices belong to companies, that documents are scoped to workspaces, or that admin users in one tenant cannot read records from another. Every application has its own ownership model and every BOLA vulnerability is a place where that model was not implemented.
This asymmetry is the root cause. Authentication gets added at the route level once and stays there. Authorization at the resource level gets added per query, per controller, per endpoint, and when it is missing, nothing tells you. The request succeeds. The response is 200. The audit log shows a valid authenticated request. Nothing looks wrong until an attacker realizes the numbers increment sequentially.
The Pattern That Lets It In
The vulnerable pattern is consistent across frameworks and languages.
// Laravel: vulnerable pattern
public function show(int $invoiceId)
{
$invoice = Invoice::findOrFail($invoiceId);
return response()->json($invoice);
}The route is protected by auth:sanctum, the user is authenticated and the invoice exists. The controller returns it. Nobody checked whether the invoice belongs to the authenticated user.
The same issue in FastAPI:
# FastAPI: vulnerable pattern
@router.get("/invoices/{invoice_id}")
async def get_invoice(
invoice_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
invoice = await db.get(Invoice, invoice_id)
if not invoice:
raise HTTPException(status_code=404)
return invoiceAuthentication passes, the invoice ID resolves and no ownership check ever runs. Every invoice in the database is accessible to every authenticated user.
The function looks correct. It even has dependency injection. It will pass a code review focused on authentication coverage. It is a BOLA vulnerability.
CVE-2024–1313: What This Looks Like in Production Software
You might think this only happens in rushed side projects. In March 2024, Grafana disclosed CVE-2024–1313 with a CVSS score of 6.5.
Grafana is the open-source observability platform used at millions of organizations. The vulnerable endpoint was DELETE /api/snapshots/{key}. A snapshot's key is not considered secret. It appears in query parameters and response bodies throughout the Grafana API. Any authenticated Grafana user could delete a dashboard snapshot belonging to a completely different organization by sending a DELETE request with that snapshot's key.
The fix was a single authorization check added to the deletion path. The absence of that check, in actively maintained software with a security team, was the entire vulnerability.
Grafana is not an isolated case. Security researchers have documented similar object-level authorization failures in production software across industries, including automotive and social platforms, where authenticated API endpoints returned or modified data belonging to other users due to missing resource-level ownership checks.
This is the pattern. It appears in production software, reviewed code and actively maintained APIs because the authorization check is optional to write and invisible when absent. Nothing in the request or response signals it is missing.
Finding It In Your Codebase
BOLA vulnerabilities cluster around predictable code patterns. An audit does not require a security scanner.
Fetch by raw user-supplied ID Any controller method that takes an ID from the URL or request body and uses it to fetch a model without also filtering by authenticated user or tenant.
Sequential or predictable object IDs Auto-increment integers are the easiest to exploit. UUIDs are harder but not safe on their own: if your API returns lists of IDs anywhere, those IDs become enumerable regardless of their format.
Admin endpoints without tenant scoping Internal admin panels often have code that explicitly bypasses the normal ownership model for legitimate reasons. That bypass leaks into non-admin endpoints during copy-paste more often than anyone admits.
Batch and list endpoints A GET /api/documents?ids=1,2,3,4,5 endpoint is as vulnerable as GET /api/documents/1 if it does not filter out IDs the caller does not own.
In practice, run this in a Laravel codebase:
# Find controller methods using findOrFail without an ownership scope
grep -rn "findOrFail" app/Http/Controllers/ | grep -v "where\|user_id\|company_id\|scoped"Each result is worth manual review. Not every match is vulnerable, but every match needs a clear reason it is safe.
The Fix in Laravel: Policies
Laravel Policies are the correct tool for resource-level authorization in Laravel 12. They separate ownership logic from controller logic, work with route model binding and are straightforward to test in isolation.
Step 1: Create the policy
<?php
namespace App\Policies;
use App\Models\Invoice;
use App\Models\User;
class InvoicePolicy
{
public function view(User $user, Invoice $invoice): bool
{
return $user->id === $invoice->user_id;
}
public function delete(User $user, Invoice $invoice): bool
{
return $user->id === $invoice->user_id;
}
}Step 2: Apply it in the controller
public function show(Invoice $invoice)
{
$this->authorize('view', $invoice);
return response()->json($invoice);
}Route model binding fetches the invoice, authorize() runs the policy, and if the check fails, Laravel returns a 403 before your controller code ever executes. One line closes the vulnerability.
The policy runs on the model already resolved by route model binding. No second database query. You are comparing IDs in memory. The performance cost is zero.
For multi-tenant applications where ownership is company-scoped rather than user-scoped:
public function view(User $user, Invoice $invoice): bool
{
return $user->company_id === $invoice->company_id;
}The logic changes per application. The pattern does not.
The Fix in FastAPI: Dependency Injection
In FastAPI, the ownership check becomes a dependency that is composed into the route, tested independently and reused across endpoints.
from fastapi import Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
async def get_invoice_for_owner(
invoice_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Invoice:
invoice = await db.get(Invoice, invoice_id)
if not invoice:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if invoice.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return invoice
@router.get("/invoices/{invoice_id}")
async def get_invoice(
invoice: Invoice = Depends(get_invoice_for_owner),
):
return invoiceTwo things worth noting in that code.
First, the endpoint no longer handles authorization. It receives an already-authorized Invoice instance from the dependency. If authorization fails, the dependency raises an exception before the endpoint function runs.
Second, both cases return HTTP_404_NOT_FOUND: "invoice does not exist" and "invoice belongs to someone else". Not 403. Returning 403 when the object exists but belongs to another user tells the attacker the ID is valid. A consistent 404 ("not found or not yours") leaks nothing about whether the object exists at all.
Write get_invoice_for_owner once and every endpoint that needs authorized invoice access uses Depends(get_invoice_for_owner). The ownership logic lives in one place.
The Harder Cases
Direct object references in sequential integers are the obvious attack surface. Three patterns are regularly missed.
Indirect references that resolve to sensitive data
A GET /api/reports/{report_id}/download endpoint might not store the report directly but uses the ID to fetch a file path or a pre-signed S3 URL. The authorization check belongs on the report object, before the file path is resolved. If the report query does not scope to the authenticated user, the file is accessible to anyone who knows a report ID.
Batch endpoints
POST /api/documents/bulk with a list of IDs needs to filter out any IDs the caller does not own before processing. The correct approach: query with a user scope and proceed only with the intersection of requested IDs and authorized IDs.
# FastAPI: safe batch fetch with user scope
authorized_docs = await db.execute(
select(Document).where(
Document.id.in_(requested_ids),
Document.user_id == current_user.id,
)
)Any ID in requested_ids that does not match the user scope is silently excluded. The response contains only what the caller owns.
GraphQL nested resolvers
A GraphQL API that resolves user { invoices { ... } } needs authorization at the resolver level. The root query might verify the caller is authenticated. The invoices resolver needs to verify the user is fetching their own invoices, not invoices belonging to a user ID passed as an argument.
Applying a Deny-by-Default Scope
Resource-level authorization is not a rule you apply once at the route level. It is a per-query discipline. One way to make it harder to forget: use a deny-by-default model scope that filters every query by the authenticated user unless you explicitly opt out.
In Laravel, this is a global scope on the model:
// In Invoice model
protected static function booted(): void
{
static::addGlobalScope('owner', function (Builder $builder) {
if (Auth::check()) {
$builder->where('user_id', Auth::id());
}
});
}Every query on Invoice now includes WHERE user_id = ? automatically. Admin endpoints that need cross-user access call Invoice::withoutGlobalScope('owner'). Opting out requires explicit code. Opting in requires nothing. The unsafe path is now visible.
The global scope is not appropriate everywhere. Reporting endpoints, admin panels and background jobs often have legitimate reasons to query across users. The point is not to block those cases but to make them require deliberate code rather than silence.
The Audit Checklist
Before deploying any endpoint that returns or modifies data by ID:
- Does the query filter by authenticated user, company or tenant?
- If not, is there an explicit authorization check after the fetch?
- Error responses for "not found" and "not authorized" should be identical. Both return 404.
- Does the endpoint exist in a batch or nested form that bypasses the individual check?
- Any related admin endpoint that shares this data layer also needs an authorization gate.
If any of these has an uncertain answer, the endpoint needs manual review before shipping.
BOLA is the number one API vulnerability not because it is complex to exploit but because the authorization check is optional to write, invisible when absent and indistinguishable from correct when present. The CI is green. The tests pass. The only signal that something is wrong is a user who incremented a number in a URL and discovered it worked.
Write the ownership check in code. If you cannot state why the caller is authorized to access this specific object, you have a BOLA vulnerability.