An IDOR vulnerability (insecure direct object reference) happens when your application takes an identifier from the request, loads the matching record and returns it without checking that the current user is allowed to see it. The attacker does not need an exploit kit. They log in with a normal account, open /api/invoices/1001, change it to /api/invoices/1002 and read another customer's invoice.
It is one of the simplest bugs to explain and one of the most common to ship. This guide covers what IDOR is, how it relates to BOLA, what vulnerable and fixed code looks like in five frameworks, the places it hides (GraphQL, bulk endpoints, multi-tenant SaaS), how to test for it and how to keep it from coming back.
What is IDOR?
A "direct object reference" is any value the client sends that points to an internal object: a database ID, a file name, an account number, a UUID. The reference itself is not the problem. The problem is the missing question on the server: is this user allowed to access this specific object?
IDOR is an authorization flaw, not an authentication flaw. The attacker is usually logged in and fully legitimate. Authentication answers "who are you?"; authorization answers "what can you touch?". IDOR is the gap between the two.
There are two flavors:
- Horizontal: a user accesses data belonging to another user with the same role (another customer's orders, messages or documents).
- Vertical: a user reaches an object or action that belongs to a higher role, for example an admin-only report fetched by ID.
IDOR vs BOLA: same bug, two names
In the OWASP Top 10 2025, IDOR lives inside A01:2025 Broken Access Control, which stays at number one. OWASP lists "permitting viewing or editing someone else's account by providing its unique identifier (insecure direct object references)" as a typical example, and maps it to CWE-639, Authorization Bypass Through User-Controlled Key. Read our overview of broken access control for the wider category.
In the OWASP API Security Top 10 (2023 edition), the same flaw is called BOLA, Broken Object Level Authorization, and it is API1:2023, the first item. OWASP describes it as attackers "manipulating the ID of an object that is sent within the request". So when people search for "bola vulnerability" or "broken object level authorization", they are looking at IDOR in an API context.
Real-world style examples
Sequential IDs
The textbook case: GET /api/orders/48213. Integer IDs are predictable, so a script can walk from 1 to 50,000 and download every order in the system in minutes. The same applies to file downloads such as /receipts/48213.pdf served from storage without a check.
IDs in the body, not the URL
Many IDORs hide in JSON bodies: {"user_id": 812, "email": "new@example.com"} sent to a profile update endpoint. The handler trusts user_id from the body instead of taking it from the session, so anyone can change anyone's email and then reset their password.
UUIDs are not authorization
A common false fix is switching to UUIDs. OWASP does recommend random, unpredictable IDs, but as defense in depth, not as the control. UUIDs leak everywhere: in shared links, browser history, logs, analytics, webhook payloads, CSV exports and other API responses (a list endpoint that returns teammates' objects, for instance). Once an attacker has one, an unchecked endpoint serves it happily. Unguessable is not the same as unauthorized.
Vulnerable vs fixed code in 5 frameworks
The pattern is identical everywhere: stop loading objects globally and start loading them through the current user or tenant. If the object is not in the user's scope, it does not exist for them, and you return 404.
Rails
# Vulnerable: any logged-in user can read any invoice
def show
@invoice = Invoice.find(params[:id])
render json: @invoice
end
# Fixed: scope through the association (raises RecordNotFound, so 404)
def show
@invoice = current_user.invoices.find(params[:id])
render json: @invoice
endFor anything beyond one association, centralize the rule in a policy. With Pundit:
class InvoicePolicy < ApplicationPolicy
def show? = record.account_id == user.account_id
class Scope < ApplicationPolicy::Scope
def resolve = scope.where(account_id: user.account_id)
end
end
# controller
def show
@invoice = policy_scope(Invoice).find(params[:id])
authorize @invoice
endPundit's after_action :verify_authorized makes a forgotten check fail loudly. With CanCanCan, the equivalent is can :read, Invoice, account_id: user.account_id in the ability plus load_and_authorize_resource in the controller.
Express / Node.js
// Vulnerable
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await Invoice.findByPk(req.params.id);
res.json(invoice);
});
// Fixed: the ownership condition is part of the query
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await Invoice.findOne({
where: { id: req.params.id, accountId: req.user.accountId },
});
if (!invoice) return res.status(404).json({ error: 'Not found' });
res.json(invoice);
});Django
# Vulnerable
def invoice_detail(request, pk):
invoice = get_object_or_404(Invoice, pk=pk)
return JsonResponse(invoice.as_dict())
# Fixed
def invoice_detail(request, pk):
invoice = get_object_or_404(Invoice, pk=pk, account=request.user.account)
return JsonResponse(invoice.as_dict())
# Django REST Framework: scope the queryset, not each view
class InvoiceViewSet(viewsets.ModelViewSet):
serializer_class = InvoiceSerializer
def get_queryset(self):
return Invoice.objects.filter(account=self.request.user.account)Laravel
// Vulnerable: route model binding loads any invoice
public function show(Invoice $invoice)
{
return $invoice;
}
// Fixed: a policy decides, the controller asks
public function show(Invoice $invoice)
{
Gate::authorize('view', $invoice);
return $invoice;
}
// app/Policies/InvoicePolicy.php
public function view(User $user, Invoice $invoice): bool
{
return $user->account_id === $invoice->account_id;
}Spring Boot
// Vulnerable
@GetMapping("/invoices/{id}")
public Invoice get(@PathVariable Long id) {
return repo.findById(id).orElseThrow();
}
// Fixed: a repository method that requires the owner
@GetMapping("/invoices/{id}")
public Invoice get(@PathVariable Long id, @AuthenticationPrincipal AppUser user) {
return repo.findByIdAndAccountId(id, user.getAccountId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}Where IDOR hides: GraphQL and bulk endpoints
GraphQL. A single schema exposes objects through many paths: invoice(id:), the generic node(id:) field, nested fields like customer { invoices { ... } } and mutations such as updateInvoice(id:). Teams often protect the top-level query and forget the nested resolver or the mutation. Put authorization in the data layer that every resolver calls (a scoped loader or policy), not in individual resolvers.
Bulk and export endpoints.POST /api/invoices/export with {"ids": [1, 2, 3]} often runs Invoice.where(id: ids). One check on the first ID, or none at all, and the attacker exports thousands of foreign records in one call. Scope the whole set: current_account.invoices.where(id: ids).
Writes, not only reads. Update and delete handlers are just as exposed, and worse: a request that sets account_id in the body can move an object into another tenant. Never accept ownership fields from the client.
Multi-tenant SaaS pitfalls
- Checking the user but not the tenant. The user belongs to account A, the project belongs to account B, and the code only checks that the user is logged in and is a project admin somewhere.
- Trusting a tenant header.
X-Account-Idsent by the frontend is client input. Derive the tenant from the session or token, then verify membership. - Child objects.
/projects/7/tasks/99checks that project 7 is yours, then loads task 99 globally. Load the child through the parent:project.tasks.find(99). - Background jobs and webhooks. A job enqueued with a raw ID, or a signed URL that never expires, bypasses the controller checks entirely.
- Admin and support tools. Internal endpoints reused by the customer app are a classic source of vertical IDOR.
Row-level security in the database (for example PostgreSQL RLS policies keyed on a tenant setting) is a strong second layer, because a forgotten scope in the application still cannot cross tenants.
How to test for IDOR
- Create two accounts (ideally in two different tenants) and a few objects in each.
- Proxy the traffic through Burp Suite while using account A. Every request that carries an ID in the path, query string, body or header is a candidate.
- Replay those requests with account B's session and account A's IDs. Burp Repeater works for a handful; extensions like Autorize automate the swap for every request.
- Try every verb: GET, PUT, PATCH, DELETE, plus exports, attachments and GraphQL mutations.
- Compare responses, not only status codes. A 200 with an empty body can hide a successful write; a 403 that differs from a 404 tells the attacker the object exists.
This is standard work in any penetration test and in AI-assisted pentesting of APIs.
Why pattern-based SAST misses IDOR
Classic SAST traces untrusted input to a dangerous sink. That works for SQL injection, where req.query.sort ends up concatenated into a query string. An IDOR has no dangerous sink: Invoice.find(params[:id]) is a perfectly safe, parameterized query. What is wrong is a condition that is absent. No regex matches a missing line.
Reasoning-based analysis approaches it the way a reviewer would. It notices that InvoicesController#show scopes through current_user while InvoicesController#download does not, that a policy exists but one action never calls authorize, or that a GraphQL mutation skips the loader every query uses. That comparison across files and conventions is where a language model is useful, and it is why secure code review has always caught IDORs that scanners missed.
Nurbak takes that approach: you connect GitHub and scan a repository, and Nurbak's own self-hosted AI model analyzes the code (the analysis does not send your code to OpenAI or Anthropic). It reports exploitable vulnerabilities, including IDOR/BOLA and missing authorization checks, with file and line and a plain-language explanation, alongside dependency CVEs and secrets in git history, all summarized in a 0 to 100 score. See how it works on the API security scanner page, or read more on how to find vulnerabilities in code.
Write regression tests for authorization
OWASP's own BOLA guidance says to write tests for the authorization mechanism and not deploy changes that make them fail. An IDOR fixed without a test tends to come back in the next refactor. The test is short: two users, one object, assert the stranger gets nothing.
# spec/requests/invoices_spec.rb
RSpec.describe "Invoices authorization", type: :request do
let(:owner) { create(:user) }
let(:stranger) { create(:user) } # different account
let!(:invoice) { create(:invoice, account: owner.account) }
it "hides another account's invoice" do
sign_in stranger
get "/api/invoices/#{invoice.id}"
expect(response).to have_http_status(:not_found)
end
it "does not let a stranger delete it" do
sign_in stranger
delete "/api/invoices/#{invoice.id}"
expect(Invoice.exists?(invoice.id)).to be(true)
end
endThe same shape works with supertest in Node, pytest with the Django test client, Pest or PHPUnit in Laravel and MockMvc in Spring. Make it a habit: every new endpoint that takes an ID ships with a "stranger" test. When Nurbak finds an issue like this, it can open a Pull Request with the fix plus a security regression test of exactly this kind (the fix is generated with Claude, only with your explicit consent).
IDOR prevention checklist
- Load objects through the current user or tenant, never globally.
- Take identity and tenant from the session or token, never from the request body or headers.
- Centralize rules in policies and make missing checks fail (deny by default).
- Return 404 for objects outside the user's scope.
- Cover nested resources, bulk endpoints, exports, file downloads, GraphQL resolvers and mutations.
- Use random IDs as defense in depth, not as the control.
- Add a two-account regression test for every endpoint that takes an ID.
Bottom line
IDOR is not exotic. It is a missing where clause, repeated across dozens of endpoints, and it gives an attacker exactly what they want: other people's data, without triggering an error. Scope every query, centralize your policies, test with two accounts and treat AI-written endpoints with the same suspicion as human ones (our guide to Claude Code security review covers that workflow). A free Nurbak scan shows the 3 most important findings in full, and plans start at USD 79/month if you want continuous coverage. Start with the API security scanner on the repository that serves your API.
