Back to Blog
IDORBroken Access ControlAuthorizationOWASPNext.jsNode.jsPrismaAPI Security

IDOR and Broken Access Control in Next.js and Node.js: Object-Level Authorization That Holds [2026]

Authentication Is Not Authorization

Your middleware runs, getSession() returns a user, and the route handler proceeds. That check answers exactly one question — is there a valid session on this request? — and says nothing about whether this session is allowed to read the object whose ID just arrived in the URL. That gap is Broken Access Control: category A01 in the OWASP Top 10 since 2021, ranked above injection and cryptographic failures. Its most common concrete form is IDOR — Insecure Direct Object Reference.

The pattern is boring, which is why it survives code review. A user changes /api/invoices/inv_1042 to /api/invoices/inv_1043 and reads someone else's invoice. No exploit payload, no encoding trick, no cryptographic break — just an identifier that was never checked against the caller. Three properties make this the most expensive bug class in the top 10:

  1. Most scanners miss it. The request is authenticated and syntactically valid. The response is a normal 200. Nothing looks anomalous to a fuzzer that does not own two accounts.
  2. It is trivially automatable. Walk the ID space, diff response sizes, extract. With autoincrement integers it is a for loop. With a /api/users?ids= batch endpoint it is one request.
  3. The blast radius is a breach. Not a defacement, not a DoS — every other tenant's records, downloadable as JSON.

This guide is about where object-level authorization actually breaks in a Next.js + Node.js codebase: the query patterns that cause it, the query patterns that fix it, why middleware cannot be the control, and the regression tests that keep it fixed.

IDOR in One Request

Two accounts, one endpoint. The attacker is a legitimate, paying, fully authenticated customer of your product:

bash
# Authenticated as account B. Account A's invoice ID is not a secret —
# it appears in logs, in emails you send, in a shared screenshot, or in a sequence.
curl -s "https://app.example.com/api/invoices/inv_1042" \
  -H "Cookie: session=<account-B-session>"

# 200 OK
# {"id":"inv_1042","tenantId":"acme","number":"INV-2026-0187",
#  "totalCents":4825000,"customerEmail":"cfo@acme.com","notes":"..."}

Nothing was bypassed. There was no authentication to bypass — the request was perfectly authenticated, as the wrong principal. If IDs are sequential (1042, 1043, 1044) a single script enumerates your entire customer table in seconds, and the response tells the attacker exactly how much each customer is worth.

Horizontal privilege escalation is this: same role, different tenant or different user. Vertical escalation is a normal user reaching admin-only objects. Both are the same missing predicate in the same query.

Where JavaScript Stacks Leak Object References

Four structural reasons this bug is so common in Node.js and Next.js specifically:

  • Route handlers are thin proxies to the ORM. findUnique({ where: { id } }) reads like a safe lookup — the ORM is parameterized, there is no SQL injection, so the code "looks secure." Parameterization protects against injection; it does nothing about authorization. The query returns the row because the row exists, not because the caller owns it.
  • The ID is already in a client-controlled position. Dynamic segments ([id]), query strings (?invoice=…), request bodies, GraphQL variables, Server Action form data — every one of them is attacker-controlled input that happens to look like a key.
  • Framework conventions hide the boundary. Next.js route handlers are not controllers with a policy layer; there is no Rails-style before_action. Authorization is something you must remember to write, in every handler, forever.
  • Related-data includes multiply the mistake. One missing predicate on a parent record is a bug; the same predicate missing on an include of a nested collection is a bulk export.

The Vulnerable Pattern — and Its Invisible Twin

ts
// app/api/invoices/[id]/route.ts — VULNERABLE
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth";

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const session = await getSession();
  if (!session) {
    return NextResponse.json({ error: "unauthorized" }, { status: 401 });
  }

  const { id } = await params;

  // Authentication passed. Ownership was never part of the question.
  const invoice = await prisma.invoice.findUnique({ where: { id } });
  if (!invoice) {
    return NextResponse.json({ error: "not found" }, { status: 404 });
  }

  return NextResponse.json(invoice); // any tenant's invoice, to any session
}

Now the write path, which is worse because it is destructive and often overlooked:

ts
// VULNERABLE — a cross-tenant write. The read bug and the write bug are the same bug.
await prisma.invoice.delete({ where: { id } });          // deletes anyone's invoice
await prisma.invoice.update({ where: { id }, data: { status: "PAID" } });

And the third variant, which reviewers approve because it looks like a check:

ts
// VULNERABLE — ownership checked after the fact, and the record already left the database
const invoice = await prisma.invoice.findUnique({ where: { id } });
if (!invoice) return notFound();
if (invoice.tenantId !== session.tenantId) return forbidden(); // 403 = existence oracle
return NextResponse.json(invoice);

That last one leaks too: 403 for "exists but not yours" and 404 for "does not exist" tells an attacker which IDs are real. It also loads the full row — including columns the UI never shows — into your process, your logs, and your APM before the check runs.

Rule 1 — Scope Comes From the Session, Never From the Request

If a client can name the scope, the client owns the scope. Never read tenantId, orgId, userId, or role from a request body, a query parameter, or a custom header for the purpose of deciding access. Those values come from the session (or the verified JWT claim) that your auth layer produced. Anything a client says about itself is a claim; only the server-side session is a fact. This is the same principle as binding socket identity at upgrade time in our WebSocket hardening guide.

Rule 2 — Put the Ownership Predicate Inside the Query

The fix is not a second query, an if after the fetch, or a middleware annotation. It is a WHERE clause. Make the database unable to return a row the caller does not own:

ts
// app/api/invoices/[id]/route.ts — SCOPED AT THE DATA LAYER
const invoice = await prisma.invoice.findFirst({
  where: {
    id,
    tenantId: session.tenantId, // the predicate that makes IDOR impossible
  },
  select: {
    id: true,
    number: true,
    totalCents: true,
    status: true,
    issuedAt: true,
    lines: { select: { description: true, amountCents: true } },
  },
});

if (!invoice) {
  return NextResponse.json({ error: "not found" }, { status: 404 });
}

return NextResponse.json(invoice);

Two notes on doing this in Prisma without making the codebase awkward:

prisma
// schema.prisma — make the compound key legal so you can use findUnique on both fields
model Invoice {
  id         String   @id @default(cuid())
  tenantId   String
  number     String
  totalCents Int
  status     String
  issuedAt   DateTime
  ownerId    String

  lines InvoiceLine[]

  @@unique([id, tenantId]) // enables findUnique({ where: { id, tenantId } })
  @@index([tenantId, issuedAt])
}

With @@unique([id, tenantId]) declared, findUnique accepts the compound selector and stays as fast as a primary key lookup — while being structurally incapable of crossing a tenant. Without it you use findFirst, which is equally correct (just be aware it compiles to a LIMIT 1 scan on the index you defined).

For writes, prefer the count-returning forms and assert on the count:

ts
// WRITE PATH — atomic, tenant-scoped, and it tells you whether it matched
const result = await prisma.invoice.updateMany({
  where: { id, tenantId: session.tenantId, status: "DRAFT" },
  data: { status: "SENT", sentAt: new Date() },
});

if (result.count === 0) {
  // Covers "doesn't exist", "not yours", and "already sent" — all 404 to the caller.
  return NextResponse.json({ error: "not found" }, { status: 404 });
}

updateMany/deleteMany with a compound where is check-and-act in a single statement: the database evaluates the predicate and applies the change atomically. That matters for the same reason as the atomic-update pattern in our race conditions guide — a find followed by an update is a TOCTOU window, and an ownership check that happens in JavaScript is a check you can race.

Rule 3 — Answer 404, Not 403

Return 404 Not Found for "exists but you cannot access it" and for "does not exist." A distinct 403 confirms the object's existence, turning your API into an ID oracle: an attacker enumerates the ID space, watches which values flip from 404 to 403, and now holds a validated list of every real object — often including the ones that are sensitive precisely because you hid them. If your product genuinely needs to tell a customer "you don't have permission to this" (shared links, team invitations), do it on objects where the ID is already a capability the user possesses — never as a generic authorization error on enumerable IDs.

Keep the distinction where it costs nothing: log the real reason server-side with the acting user and the object ID, and alert on repeated 404s from a single session walking a sequence. That is your IDOR detection signal.

Rule 4 — Centralize Authorization at One Chokepoint

Scattered if checks rot. New route, no check; second implementation of an export endpoint, no check; a background job reusing the repository, no check. The durable structure is a single module every handler must pass through:

ts
// lib/authz.ts — one module, one audit target
import { prisma } from "@/lib/db";
import type { Session } from "@/lib/auth";

type Action = "read" | "update" | "delete" | "share";

export async function authorizeInvoice(
  session: Session,
  invoiceId: string,
  action: Action,
) {
  const invoice = await prisma.invoice.findFirst({
    where: { id: invoiceId, tenantId: session.tenantId }, // scope is baked in here
    select: {
      id: true,
      ownerId: true,
      status: true,
      tenant: { select: { plan: true } },
    },
  });

  // Missing and not-yours collapse to the same answer. No oracle.
  if (!invoice) return null;

  const isOwner = invoice.ownerId === session.userId;
  const isAdmin = session.role === "admin";

  // Object-level (ownership) and role-level (vertical) rules in one place.
  if (action !== "read" && !isOwner && !isAdmin) return null;

  // Entitlement rules too: plan limits belong next to permission rules.
  if (action === "share" && invoice.tenant.plan === "starter") return null;

  return invoice;
}

Usage stays one line, and the failure mode is a 404 with no information disclosure:

ts
// app/api/invoices/[id]/route.ts
const invoice = await authorizeInvoice(session, id, "read");
if (!invoice) return NextResponse.json({ error: "not found" }, { status: 404 });

Then make the bypass impossible to introduce by accident: forbid direct ORM access outside the repository layer with a lint rule, and export only the authorized accessors to route code.

jsonc
// eslint.config.js — ban unscoped ORM calls outside lib/repos and lib/authz
{
  "files": ["app/**/*.{ts,tsx}", "src/app/**/*.{ts,tsx}"],
  "rules": {
    "no-restricted-syntax": [
      "error",
      {
        "selector": "MemberExpression[property.name=/^findUnique$|^findFirst$|^update$|^delete$/]",
        "message": "Use an authorize* helper or a scoped repository function. Unscoped lookups by id are how IDOR ships."
      }
    ]
  }
}

The lint rule is the difference between a policy and a practice. Without it, rule 4 is a convention that the next contractor has not read.

Rule 5 — Middleware Is Not an Authorization Layer

middleware.ts is famously good at one thing: redirecting unauthenticated users away from route groups. It is not an object-level control, for three concrete reasons:

  1. It sees the path, not the object. matcher: ["/api/invoices/:path*"] runs before the ID exists as a database fact. It can gate "is logged in," never "owns inv_1042."
  2. Matchers drift. New route groups, internal handlers imported directly by other handlers, cron endpoints under /api/cron, webhook receivers — anything outside the matcher is unguarded, and nothing fails loudly when you add a path that does not match.
  3. Code paths exist that skip it. Server Actions and some internal fetches execute their handler directly; middleware is a routing-layer concern, not a function-call boundary. If authorization only happens in middleware, authorization is optional.

Use middleware for coarse gating (auth required, locale, security headers) and put the object-level decision inside the handler, on the code path that actually touches the row.

Rule 6 — Server Actions and RSC Arguments Are Client Input

A Server Action argument arrives from the browser. React encrypts bound arguments so they cannot be tampered with in transit, but the value is still chosen client-side: an attacker calls your action with their own arguments, exactly like calling an API endpoint. Server Components have the same exposure in reverse — props and search params are input, and any data a Server Component fetches with a client-supplied ID must be scoped before it is rendered and serialized into the RSC payload.

ts
// app/(app)/invoices/actions.ts
"use server";

import { requireSession } from "@/lib/auth";
import { authorizeInvoice } from "@/lib/authz";
import { prisma } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { notFound } from "next/navigation";

export async function deleteInvoice(formData: FormData) {
  const session = await requireSession();

  // Client input. It looks trusted because TypeScript says it is a string.
  const id = String(formData.get("id") ?? "");

  // Re-authorize INSIDE the action, on the resolved object.
  const invoice = await authorizeInvoice(session, id, "delete");
  if (!invoice) notFound();

  await prisma.invoice.deleteMany({
    where: { id: invoice.id, tenantId: session.tenantId },
  });

  revalidatePath("/invoices");
}

The rule is blunt: the action is a public endpoint. Treat every parameter as hostile, then run the same scoped read or scoped write you would run in a route handler.

Rule 7 — Fetch Less: select, Not include

Over-fetching is the silent half of broken access control. A scoped query that then pulls the entire object graph still leaks — just internally, into serialization, cache, and logs:

ts
// OVER-EXPOSING — the tenant predicate is right, the field selection is not
const invoice = await prisma.invoice.findFirst({
  where: { id, tenantId: session.tenantId },
  include: {
    customer: true,     // every column: billing address, tax ID, internal notes
    apiKeys: true,      // never belongs in a client response
    auditLog: true,     // internal actor emails, IPs
    comments: true,     // sibling records with their own tenant semantics
  },
});

Replace each include with an explicit select allowlist, and check nested collections for their own tenant predicate. In GraphQL the same discipline applies at field level: a resolver that returns Invoice must not resolve invoice.customer for a viewer without access to that customer — field resolvers are authorization boundaries too, which is the subject of our GraphQL security guide.

Defense in Depth: Row-Level Security in PostgreSQL

Application-layer scoping is your primary control, and like every primary control it depends on a human remembering it. Row-Level Security (RLS) makes the database the second line of defense: even an unscoped query returns nothing the session cannot see.

sql
-- Enable RLS on tenant-scoped tables
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;  -- applies to the table owner too

CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id', true))
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true));

Set the tenant on every request's connection, inside a transaction so the setting cannot leak across pooled connections:

ts
// lib/db.ts — run the request inside a transaction with the tenant bound
export function withTenant<T>(
  tenantId: string,
  fn: (tx: Prisma.TransactionClient) => Promise<T>,
): Promise<T> {
  return prisma.$transaction(async (tx) => {
    // SET LOCAL scopes the setting to this transaction only.
    await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
    return fn(tx);
  });
}

Three operational caveats, because a misconfigured RLS policy is worse than none:

  • The application's database role must not be a superuser and must not hold BYPASSRLS. Superusers and (by default) table owners skip policies unless you use FORCE ROW LEVEL SECURITY.
  • RLS is per-transaction intent: if your ORM or a job opens its own connection outside withTenant, current_setting is unset — with missing_ok = true (the true second argument above) the policy silently matches nothing.
  • Migrations, admin tooling, and analytics jobs need an explicit, audited bypass role. Do not widen the app role to cover them.

Batch Endpoints and GraphQL: The Same Bug With More IDs

Single-object routes are where the bug is found. Batch endpoints are where it scales:

ts
// VULNERABLE — nodes()/ids[] resolves anything, one missing predicate, 500 rows
const invoices = await Promise.all(
  ids.map((id) => prisma.invoice.findUnique({ where: { id } })),
);

Any endpoint that accepts a list of IDs — ?ids=, GraphQL nodes(ids: [...]), a Relay node(id:) interface, a bulk export — multiplies the impact of a missing predicate by the size of the list. Two rules apply:

  • Filter the list, don't validate it. One query with where: { id: { in: ids }, tenantId: session.tenantId } and compare the returned count against the requested count. A mismatch is not an error to swallow — it is an authorization event worth logging and alerting.
  • A global ID is not a capability. Base64-encoding Invoice:42 into gid://app/Invoice/42 changes nothing about who may read it, and Relay's global ID convention has convinced more than one team that opaque IDs are a security boundary. They are not; they are a naming scheme.

Regression Tests That Catch IDOR Automatically

Manual review does not scale, and scanners only find this if they hold two accounts. A two-account matrix runs in CI and fails the build when someone adds a route without a scope:

ts
// tests/authz/idor.test.ts
import { describe, it, expect } from "vitest";

const BASE = process.env.TEST_BASE_URL!;

describe("object-level authorization — invoices", () => {
  it("hides another tenant's invoice on read (404, not 403)", async () => {
    const { cookie: cookieA, invoiceId } = await seedAccount("acme");
    const { cookie: cookieB } = await seedAccount("globex");

    const res = await fetch(`${BASE}/api/invoices/${invoiceId}`, {
      headers: { cookie: cookieB },
    });

    // 404 confirms nothing about existence — no oracle.
    expect(res.status).toBe(404);
    // And the body must not leak any field of the record.
    expect(await res.text()).not.toMatch(/INV-|acme/i);
  });

  it("blocks the cross-tenant write path", async () => {
    const { cookie: cookieA, invoiceId } = await seedAccount("acme");
    const { cookie: cookieB } = await seedAccount("globex");

    const res = await fetch(`${BASE}/api/invoices/${invoiceId}`, {
      method: "DELETE",
      headers: { cookie: cookieB },
    });

    expect(res.status).toBe(404);
    expect(await invoiceStillExists(invoiceId)).toBe(true); // A's data is intact
    expect(await invoiceModifiedBy(invoiceId)).not.toBeTruthy();
  });

  it("keeps the owner's own access working", async () => {
    const { cookie: cookieA, invoiceId } = await seedAccount("acme");

    const res = await fetch(`${BASE}/api/invoices/${invoiceId}`, {
      headers: { cookie: cookieA },
    });

    expect(res.status).toBe(200); // never "secure" at the cost of correctness
  });

  it("does not leak through batch endpoints", async () => {
    const { invoiceId: a1 } = await seedAccount("acme");
    const { cookie: cookieB, invoiceId: b1 } = await seedAccount("globex");

    const res = await fetch(`${BASE}/api/invoices?ids=${a1},${b1}`, {
      headers: { cookie: cookieB },
    });

    const body = (await res.json()) as { id: string }[];
    expect(body.map((i) => i.id)).toEqual([b1]); // only own rows, silently filtered
  });
});

Run the same three cases for read, update, and delete on every resource that has an owner: invoices, documents, projects, files, API keys, webhook configs. Two habits turn the suite into real coverage:

  • Pair each route with a test. Keep a route-to-test map and fail CI when a new file under app/api/** has no cross-tenant test. Uncovered surfaces are exactly where the bug lands.
  • Test the soft variants too. Cross-tenant reads through nested includes, exports and CSV reports (often a second implementation of the same query), and webhook handlers that trust an invoiceId from the payload — the signature proves the sender, not who owns the object. Our race conditions and business logic guide covers the related class where the check exists but the read and the write are not atomic.

Pitfalls That Quietly Reopen the Hole

  • Checking ownership after the fetch. The row already entered your process, your logs, and your traces. Scope in the query; a 403 after a load is a leak with extra steps.
  • 403 vs 404 asymmetry. A distinct forbidden response is an existence oracle. One answer, always.
  • The write path forgotten. Reads get the test; delete and update inherit the bug because "we already checked on the way in."
  • Soft deletes unfiltered. deletedAt is not a security boundary — a deleted invoice is still a leaked invoice if a query does not exclude it.
  • No tenant predicate on update/delete. where: { id } on a write is the exact same missing predicate as the read.
  • Nested includes with no scope. Parent roped in, children open. Check every relation your serializer touches.
  • Lint/CI green, policy absent. Without a chokepoint module and a restricted-ORM rule, the fourth copy of the query has no constraint at all.
  • Authenticated responses cached publicly. Cache-Control: public or a CDN in front of /api/ turns one authorized response into a shared one. Use private, no-store and vary on the session cookie.
  • Client-side role checks. A hidden admin button is not access control. Vertical escalation is tested by calling the endpoint directly, which is exactly what your tests should do.
  • Global IDs treated as capabilities. Opaque, base64, Relay-style — none of it is authorization.
  • Second implementations. The export, the report, the admin console, and the internal script each re-query the data with their own filters. Authorize once, and route all four through it.

The CTO Checklist

  • [ ] Every handler that accepts an object ID scopes its query by the session's tenant or user — findFirst({ where: { id, tenantId } }) or a compound findUnique. Bare findUnique({ where: { id } }) is banned.
  • [ ] Write paths use updateMany/deleteMany with the same predicate and assert count === 1 (or treat 0 as 404).
  • [ ] "Not yours" and "does not exist" return the same status and the same body. No existence oracle, in any endpoint.
  • [ ] Authorization lives in one auditable module (lib/authz.ts), and a lint rule forbids unscoped ORM calls in route and action code.
  • [ ] Middleware is used only for coarse gating; object-level decisions happen in the handler on the path that touches the row.
  • [ ] Every Server Action, RSC data fetch, and webhook handler treats its IDs as hostile client input and re-authorizes on the resolved record.
  • [ ] Responses use explicit select allowlists; nested collections have their own tenant predicate or are absent.
  • [ ] PostgreSQL RLS is enabled on tenant-scoped tables; the app role is not a superuser, is not the table owner, and lacks BYPASSRLS.
  • [ ] Authenticated responses are private, no-store and caches vary on the session cookie.
  • [ ] A two-account matrix (read/update/delete × cross-tenant × cross-role) covers every resource and runs in CI; a new route without a test fails the build.
  • [ ] Repeated 404s from one session walking an ID sequence trigger an alert — that is the IDOR detection signal you actually get.

Conclusion

Broken access control is not exotic and it is not a framework bug. It is a WHERE clause that was never written, in a file where every other line looks correct. Next.js and Node.js make it easy to write: thin handlers, a friendly ORM, params.id in a dynamic segment, Server Actions that read like local function calls. The countermeasures are equally unremarkable, which is the good news — session-derived scope, the ownership predicate inside the query, a single 404 answer, one authorization chokepoint, explicit field selection, RLS as a net, and a regression suite with two accounts.

If you remember one line from this article, make it this: the object ID is input, and the session is the only thing that grants access to it.

Pick your highest-value endpoint — the one that returns money, PII, or documents — and check it right now: does the query contain the tenant predicate, or does the handler merely authenticate and then trust the ID? Then work outward from there: every resource, both write paths, the exports, and finally the tests that lock it all down.

Want an experienced pair of eyes on your access control? Book a security audit — we review route handlers, Server Actions, RLS policies, and cache behavior against a two-account IDOR matrix, and hand you a prioritized list of the objects your users can currently reach without authorization.

Next week: Docker Security for Node.js Deployments — hardened base images, multi-stage builds without secrets baked into layers, non-root runtime users, read-only filesystems, and why --privileged undoes everything you just read.

Share this article:TwitterLinkedIn
JS

JS Security Audit

Audits led by a senior JavaScript security engineer with 10+ years of experience.