IDOR Prevention Checklist for Next.js + Supabase APIs

An IDOR slipped past code review during a billing rewrite. Here is the 6-point checklist and defense-in-depth approach I now use on every pull request.

Share

The IDOR That Slipped Past Code Review (and the Checklist I Now Use) | AI PM Portfolio

The IDOR That Slipped Past Code Review (and the Checklist I Now Use)

April 11, 2026 · 12 min read · Security / Next.js + Supabase

Last Updated: 2026-04-11

An Insecure Direct Object Reference (IDOR) vulnerability occurs when an API returns data based on a user-supplied ID without verifying the requesting user owns that resource. During a billing system rewrite, one of my API routes fetched invoices by ID with no ownership check -- any authenticated user could view any other user's invoice by changing the URL parameter. The fix required adding WHERE user_id = currentUser.id to every query and layering Row Level Security as defense-in-depth. Here is the 6-point checklist I now run on every pull request.

How does an IDOR vulnerability actually work?

IDOR -- Insecure Direct Object Reference -- is OWASP's #1 API security risk (API1:2023, Broken Object Level Authorization). The attack is simple: an authenticated user changes an ID in a request to access another user's data. No SQL injection. No XSS. No sophisticated exploit. Just changing /api/invoices/123 to /api/invoices/456.

According to a 2024 Salt Security report, 78% of API attacks target authentication and authorization flaws, and IDOR is the most common authorization vulnerability found in production APIs. According to HackerOne's 2023 bug bounty data, IDOR accounted for 15% of all valid vulnerability reports -- the single most reported category.

The reason it is so prevalent: the code "looks correct." It fetches data. It returns data. A reviewer reading for logic errors, null checks, and type safety will not notice the missing ownership check unless they are specifically looking for it.

What happened during the billing rewrite?

I was shipping a major overhaul to the admin billing section of a tax-tech SaaS application -- 15 fixes in a single sprint, including an edit invoice modal, per-line-item discount support, mobile responsive layouts, and Dialog component refactoring for accessibility. The PATCH, DELETE, and send-invoice routes all needed to work with invoice IDs passed from the client. I've written before about shipping fast in regulated industries -- this was one of those sprints where velocity created a blind spot.

Here is what the vulnerable code looked like:

VULNERABLE

// app/api/invoices/[id]/route.ts
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const body = await req.json();

  // BUG: fetches invoice by ID only -- no ownership verification
  const { data, error } = await supabase
    .from('invoices')
    .update({ ...body, updated_at: new Date().toISOString() })
    .eq('id', params.id)  // any valid invoice ID works
    .select()
    .single();

  return NextResponse.json(data);
}

The code authenticates the user (line 3-4). It validates the request body. It updates the record. A code reviewer scanning for null handling, type safety, and API contract correctness would see nothing wrong. The authentication check is there. The query is parameterized. The response is typed.

But any authenticated user who guesses or enumerates another invoice ID can modify that invoice. The query filters on id only -- it never checks whether the requesting user owns that invoice.

Why did the reviewer miss it?

Three factors combined:

  1. Functional focus: The review was checking whether the edit modal worked correctly -- did the PATCH update the right fields? Did line items replace properly? The reviewer was in "does it work" mode, not "who can access it" mode.
  2. Auth presence illusion: The getUser() call at the top of the route creates a false sense of security. Authentication was present. Authorization was absent. Most developers conflate the two.
  3. No automated detection: The CI pipeline ran TypeScript checks, ESLint, and E2E tests. None of those catch missing ownership filters. There was no DAST scanner, no IDOR-specific lint rule, and no authorization test pattern in the test suite.

What does the fixed code look like?

FIXED

// app/api/invoices/[id]/route.ts
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const body = await req.json();

  // FIX: ownership check -- user_id must match the authenticated user
  const { data, error } = await supabase
    .from('invoices')
    .update({ ...body, updated_at: new Date().toISOString() })
    .eq('id', params.id)
    .eq('user_id', user.id)  // only the owner can modify
    .select()
    .single();

  if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 });

  return NextResponse.json(data);
}

Two changes: .eq('user_id', user.id) added to the query (line 14), and a null check on the result (line 17). If the invoice exists but belongs to another user, the query returns no rows and the API returns a 404 -- not a 403, because returning 403 would confirm the resource exists, which is itself an information leak.

Why return 404 instead of 403?

Returning 403 Forbidden tells an attacker "this invoice exists, you just can't access it." That confirms valid IDs, making enumeration easier. Returning 404 Not Found is indistinguishable from a genuinely nonexistent resource. OWASP recommends 404 for authorization failures on enumerable resources. The attacker learns nothing.

What is the defense-in-depth layer? (Row Level Security)

Application-level ownership checks are necessary but not sufficient. A single missed .eq('user_id', user.id) in any route reopens the vulnerability. Row Level Security (RLS) in Supabase/PostgreSQL acts as a database-level safety net -- even if the application code forgets the filter, the database enforces it. I covered RLS architecture patterns in an earlier post.

-- RLS policy: users can only see their own invoices
CREATE POLICY "Users can view own invoices"
  ON invoices FOR SELECT
  USING (user_id = auth.uid());

CREATE POLICY "Users can update own invoices"
  ON invoices FOR UPDATE
  USING (user_id = auth.uid())
  WITH CHECK (user_id = auth.uid());

With RLS enabled, the vulnerable code from earlier would return zero rows instead of leaking data -- the database itself filters out rows the user does not own. The application-level check is belt; RLS is suspenders.

The RLS gotcha that cost me a month: When a table has triggers that access auth.users or other restricted schemas, those trigger functions must be created with SECURITY DEFINER so they execute as the postgres role. Without it, the trigger runs as the calling user (authenticated), which lacks access to auth.* tables. Additionally, foreign keys with ON DELETE SET NULL internally perform an UPDATE -- so the target table needs an UPDATE RLS policy, not just a DELETE policy. These two issues combined caused persistent "permission denied" errors that were extremely difficult to diagnose because the error appeared on DELETE operations but the actual failure was on an implicit UPDATE.

What is the 6-point IDOR prevention checklist?

After shipping the fix, I codified the review pattern into a checklist that I now run on every pull request that touches an API route. The checklist takes under 5 minutes per route and has caught 3 additional IDOR risks in the 4 weeks since I started using it.

  1. Every API route that takes an ID parameter: Does it verify the requesting user owns that resource? Check for .eq('user_id', user.id) or equivalent ownership filter in the database query. Authentication alone (getUser()) is not enough.
  2. Every database query: Does it include a user_id filter or rely on an active RLS policy? If neither, the query is vulnerable. Run SELECT tablename, policyname FROM pg_policies WHERE tablename = 'your_table' to verify RLS exists.
  3. Every file or document access: Does it verify the upload belongs to the requesting user? Storage buckets often have their own access policies separate from table RLS. Verify the storage bucket is private and has appropriate policies.
  4. Every admin route: Does it verify the admin role, not just authentication? An authenticated user is not necessarily an admin. Check for role verification: if (profile.role !== 'admin') return 403.
  5. Batch endpoints: Does it verify ALL IDs in the batch, not just the first? A common pattern is validating the first ID for ownership then processing the rest without checks. Every ID in a batch must be verified.
  6. Related resources: If accessing an invoice, does it verify the user owns the parent account? Accessing /api/accounts/123/invoices must verify ownership of account 123, not just the existence of invoices under it.

How do the IDOR prevention approaches compare?

Approach Manual Ownership Checks Row Level Security (RLS) Authorization Middleware DAST Scanning
Where it runs Application code (per route) Database (per table) Application code (centralized) CI/CD pipeline (post-deploy)
Coverage Only routes where added All queries on protected tables All routes using the middleware Discovers gaps via black-box testing
Failure mode Forgetting to add it (silent) Forgetting to enable RLS on new table Bypassing middleware accidentally False negatives (does not test all paths)
Setup cost Low (1 line per query) Medium (policies per table per operation) Medium (build once, apply everywhere) High (OWASP ZAP config, auth setup)
Catches new routes? No -- must remember to add Yes -- if RLS is on the table Yes -- if all routes use middleware Partially -- depends on crawl coverage
Best for Quick hardening of existing routes Defense-in-depth on all data access New apps designed with auth from day 1 Auditing existing apps for gaps

My recommendation: use at least two layers. Application-level ownership checks plus RLS is the minimum viable security posture for any multi-tenant SaaS. Adding DAST scanning (we use OWASP ZAP in nightly CI) catches the routes you forgot. Authorization middleware is ideal for greenfield projects but difficult to retrofit onto an existing codebase with 100+ routes.

How do you test for IDOR vulnerabilities?

The testing method is straightforward: create two test users, authenticate as each, and try to access each other's resources. Here is the pattern I use with two Supabase test accounts:

// idor-test.ts -- swap tokens between two test users
const USER_A_TOKEN = process.env.TEST_USER_A_TOKEN!;
const USER_B_TOKEN = process.env.TEST_USER_B_TOKEN!;

// Step 1: Create an invoice as User A
const invoice = await fetch('/api/invoices', {
  method: 'POST',
  headers: { Authorization: `Bearer ${USER_A_TOKEN}` },
  body: JSON.stringify({ amount: 5000, description: 'Tax filing' }),
}).then(r => r.json());

console.log('Created invoice:', invoice.id);
// Output: Created invoice: inv_a1b2c3d4

// Step 2: Try to access User A's invoice as User B
const unauthorized = await fetch(`/api/invoices/${invoice.id}`, {
  headers: { Authorization: `Bearer ${USER_B_TOKEN}` },
});

console.log('Status:', unauthorized.status);
// Expected output: Status: 404
// If you see Status: 200 -- you have an IDOR vulnerability

// Step 3: Try to PATCH User A's invoice as User B
const patchAttempt = await fetch(`/api/invoices/${invoice.id}`, {
  method: 'PATCH',
  headers: { Authorization: `Bearer ${USER_B_TOKEN}` },
  body: JSON.stringify({ amount: 0 }),
});

console.log('Patch status:', patchAttempt.status);
// Expected output: Patch status: 404
// If you see Patch status: 200 -- critical IDOR on write path

I run this pattern against every API route that accepts an ID parameter. The test takes approximately 2 minutes per route and is the single most effective security test I know. In a codebase with 189 API routes, I prioritized the 23 routes that handle financial data, PII, or document access -- those are the routes where an IDOR has material impact.

What about automated IDOR detection in CI?

OWASP ZAP's active scanner includes IDOR checks, but they require authenticated scanning with multiple user contexts. Our nightly QA pipeline runs ZAP with a baseline scan configuration. The scanner tests 35 route groups across 15 test categories, running at 2 AM nightly. It caught one additional missing ownership check on a document download route that manual review had missed -- the route verified the document existed but not that the requesting user owned it.

For teams using Next.js with the App Router, there is no built-in IDOR linting rule. The closest automated check is a custom ESLint rule that flags any Supabase .eq('id', params.id) that is not accompanied by a .eq('user_id', ...) on the same query chain. I have not seen this rule published anywhere -- it is something we built internally after the incident.

What are the non-obvious IDOR variants to watch for?

The classic IDOR -- changing an ID in a URL -- is the most documented variant. But three less-discussed patterns appear regularly in Next.js + Supabase applications:

1. The storage bucket IDOR

Supabase Storage uses separate policies from table RLS. A private table with proper RLS can still have a public storage bucket. If documents are stored with predictable paths like /tax-documents/{userId}/{filename}, an attacker who knows the user ID and filename can download the file directly, bypassing all application-level checks. The fix: make storage buckets private and use signed URLs with expiration.

2. The webhook IDOR

Webhook endpoints (Stripe, payment processors) often receive resource IDs from external services. If the webhook handler trusts the incoming ID without verifying it maps to a valid resource in your system, an attacker who can craft webhook-like requests can trigger operations on arbitrary resources. Verify webhook signatures AND validate resource ownership.

3. The aggregate query IDOR

Endpoints that return aggregated data -- "total invoices for account X" or "average filing time for user Y" -- often skip ownership checks because the response is not raw data. But aggregate data still leaks information: knowing another user has 12 invoices totaling $45,000 is a privacy violation, even without seeing individual invoice details.

What did we add to the security pipeline after this incident?

The IDOR discovery triggered a broader security hardening sprint that shipped 4 categories of improvements over 2 weeks:

  • Data protection: AES-256-GCM field encryption on SSN and DOB fields (zero plaintext remaining), PII scrubbing in all logger output, Cache-Control: no-store on 6 PII-adjacent API routes
  • Browser security: Nonce-based Content Security Policy per request, eliminating unsafe-inline for scripts, with strict-dynamic for third-party script chains
  • Audit trail: insertAuditLog() on admin client views, document downloads, and PII access, creating a forensic trail for any future incident investigation
  • CI/CD security: npm audit --audit-level=high in CI, CodeQL static analysis on push and weekly, OWASP ZAP DAST baseline in nightly QA, Dependabot alerts enabled

Total time from IDOR discovery to full hardening: 14 days. The IDOR fix itself took 30 minutes -- adding .eq('user_id', user.id) to 5 routes. The systemic response took 13.5 additional days. That ratio -- 30 minutes for the fix, 2 weeks for the systemic improvement -- is typical. The bug is the symptom; the missing security culture is the disease.

The authentication vs. authorization confusion is the root cause of most IDORs. Authentication answers "who is this user?" Authorization answers "can this user access this resource?" Every IDOR I have encountered in production was caused by code that correctly answered the first question and never asked the second. If your code review checklist does not explicitly separate these two concerns, IDORs will slip through.

Frequently Asked Questions

What is the difference between IDOR and broken access control?

IDOR is a specific type of broken access control (OWASP A01:2021). Broken access control is the broad category; IDOR is the pattern where the vulnerability is exploited by manipulating a direct reference to an object -- typically an ID in a URL, query parameter, or request body. All IDORs are broken access control, but not all broken access control is IDOR.

Does Row Level Security eliminate the need for application-level ownership checks?

No. RLS is defense-in-depth, not a replacement. Application-level checks give you control over error responses (404 vs 403), logging, and business logic. RLS is the safety net for when application code fails. Use both. If forced to choose one, choose RLS -- it covers all query paths, not just the ones you remembered to protect.

How do I test for IDOR in a Next.js App Router application?

Create two test user accounts. Authenticate as User A, create a resource, note the ID. Authenticate as User B, request that ID via the same API route. If User B receives a 200 with User A's data, you have an IDOR. Repeat for every route that accepts a resource ID -- GET, PATCH, DELETE, and any custom actions like "send" or "archive."

Can UUIDs prevent IDOR attacks?

UUIDs make enumeration harder but do not prevent IDOR. A UUID leaked in a URL, email, log, or error message can be used the same way as a sequential integer ID. UUIDs are obscurity, not security. Always verify ownership regardless of ID format.

How many routes should I audit first for IDOR?

Start with routes that handle financial data (invoices, payments, billing), PII (profiles, SSNs, addresses), and documents (uploads, downloads, signed URLs). In a typical SaaS application, this is 10-15% of routes but covers 90% of the risk surface. In our 189-route application, we prioritized 23 routes and found 4 IDOR vulnerabilities across them.


Dinesh Challa is an AI Product Manager building production software with Claude Code. Follow him on LinkedIn.

Published April 11, 2026. Part of a series on building secure, production-grade SaaS applications with Next.js, Supabase, and AI.