IDOR: the bug class that keeps killing SaaS startups
If you do enough pen tests against SaaS applications, one finding shows up more than any other. Not XSS. Not SQL injection. Not authentication bypass. The single most common critical-severity finding in modern SaaS, by a wide margin, is broken object-level authorization — IDOR, in older parlance.
It shows up across stacks, across team sizes, across teams that "do security right." Here's why, and what actually fixes it.
The basic shape
You have an endpoint like:
GET /api/v1/invoices/INV-4729
The handler:
app.get('/api/v1/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoices.findById(req.params.id);
if (!invoice) return res.status(404).json({ error: 'not_found' });
res.json(invoice);
});
requireAuth makes sure the request has a valid session. Looks fine. It's not.
Nothing in this handler checks that the currently authenticated user owns or is permitted to view invoice INV-4729. Any logged-in user — including a free-tier customer of yours — can iterate INV-1, INV-2, INV-3 and read every invoice in your system.
That's IDOR. The "I" stands for "insecure direct object reference"; modern OWASP literature calls it BOLA (broken object-level authorization). Same bug.
Why it keeps happening
The reason this bug is so common in 2026, in 2024, in 2018, in 2012 — is that it sits at the intersection of two things that don't fail loudly:
The bug doesn't crash anything. Unit tests pass. Integration tests pass. The endpoint returns 200. Your monitoring sees no errors. There's nothing to alert on.
The fix isn't where the code is written. The developer writing the invoice endpoint usually isn't thinking about authorization — they're thinking about the invoice. The authorization logic, if it exists, lives in a middleware they didn't write or a permissions helper they didn't call.
This is also why frameworks rarely fix it for you. Rails won't stop you from Invoice.find(params[:id]). Django won't stop you from Invoice.objects.get(pk=invoice_id). NestJS won't stop you from this.repo.findOne(id). The framework assumes you know whether the current user should be able to access that object. The framework is wrong to assume that, but here we are.
The four classes of IDOR you'll actually find
After looking at hundreds of these in real pen tests, IDORs cluster into four shapes:
1. Direct primary-key IDOR (the basic case). Endpoint takes an integer or UUID, looks up the record, doesn't check ownership. Example: GET /users/:id/profile. Switch IDs, read other users' profiles.
2. Tenant-boundary IDOR. Endpoint correctly checks the requesting user is logged in and belongs to some tenant — but doesn't check the requested resource belongs to the same tenant. Example: GET /api/orgs/:orgId/members returns members for any orgId, not just the requesting user's. This is the most damaging class because it breaks multi-tenancy guarantees, which are the foundational promise of SaaS.
3. Role-based IDOR. Endpoint correctly checks tenant boundary but doesn't check role. Example: an "admin" endpoint that requires only requireAuth, not requireAdmin. Any tenant member can hit /admin/api/audit-log and read the org-wide audit log.
4. Indirect IDOR. Endpoint doesn't directly accept the resource ID but accepts something that resolves to it. Example: a webhook endpoint that takes a customer email and looks up that customer's account — letting an attacker fish for accounts by trying common emails. Or a search endpoint that returns IDs, which can then be used in other (vulnerable) endpoints.
How modern pen testers find these
The detection technique that works is two-account replay. You provision two test accounts in different states — different users in the same tenant, different tenants, different roles. You capture the full set of authenticated requests from account A using a tool like Burp or our internal capture. Then you replay every one of those requests as account B and look at the responses.
Anywhere account B gets a 200 where it should have gotten a 403 (or 404), you have an IDOR candidate. The candidate still needs human verification — sometimes a 200 is intentional (a shared resource, a public view) — but the replay narrows millions of possible IDOR locations down to dozens of actual candidates.
CyberGrid's automated layer does this as part of every pen test. Manual review of the flagged candidates is where the senior tester earns their fee.
The five patterns that actually prevent IDOR
Pattern 1: ownership-aware queries by default. Make it impossible to query for a resource without the current user as a parameter. Instead of Invoice.findById(id), force every query through currentUser.invoices.findById(id). The relation does the authorization for you. Rails has this in current_user.invoices.find(params[:id]). Django has it via custom managers. If you're hand-rolling SQL, every query should have WHERE user_id = $1 OR tenant_id = $1.
Pattern 2: opaque IDs everywhere. Don't expose database primary keys to clients. Use ULID or KSUID — sortable opaque IDs — for everything customer-facing. Even with IDOR, opaque IDs raise the cost of mass-iteration significantly because attackers can't just for (i = 0; i < 10000; i++). This is not a fix on its own (don't trust security through obscurity), but it stacks.
Pattern 3: a single authorization checkpoint per request. Centralize authorization in middleware or a request-context object. Every handler should be able to ask "can the current user perform action X on resource Y?" and have one authoritative answer. Tools like CASL (JS), Pundit (Ruby), django-guardian (Python), Cerbos (cross-stack), Oso (cross-stack) all implement this pattern.
Pattern 4: integration tests that include IDOR cases by default. Every test for "can the owner see this?" should have a paired test for "is the non-owner blocked?". Make this part of your test scaffolding so it's harder to skip than to include.
Pattern 5: a "two-account smoke test" in CI. Provision two accounts in CI. Hit every authenticated endpoint as both accounts. Diff the responses. Any endpoint that returns the same content for both accounts is either intentionally shared or an IDOR. The shared list is small; you maintain it manually.
The forensics question: how do you know if you've been hit?
If you have access logs that include the authenticated user ID alongside the request path with the resource ID, you can scan retroactively. For every endpoint that takes an object ID, look for cases where the same user accessed objects belonging to many other users in a short window. The signature is usually obvious: a sequential or near-sequential pattern of ID access.
If you don't have those logs, you have a logging gap to fix before the next pen test — and you won't be able to retroactively prove safety to a customer who asks. Better to have the logs and never need them.
Common questions we get asked
"We use UUIDs, are we safe?" No. UUIDs make mass enumeration harder but don't prevent IDOR. An attacker who obtains one UUID (from a shared link, a webhook, a partner's leaked log) can still access it.
"We have RBAC, are we safe?" Probably not. RBAC stops you from doing actions outside your role. It typically doesn't stop you from acting on resources outside your scope, which is the IDOR shape.
"We use GraphQL, does that change things?" It makes the discovery harder but doesn't make the bugs go away. Every resolver in a GraphQL schema needs the same ownership check a REST endpoint would need. Many GraphQL libraries make this easier to forget because resolvers are smaller and feel more declarative.
IDOR is the bug class that has paid out the most in bug bounties for the past decade and continues to do so. It's the highest-yield place a pen tester can spend their hours. It's also the place where two hours of architectural work pays back the most — implement Pattern 1 once at the data-access layer and you've inoculated 80% of your endpoints.
Want to see this in practice?
Run a free single-domain scan in three minutes — same engine, smaller scope, no signup. We'll email you the PDF.
Run a free scan