Broken access control: the bug AI CRUD ships by default

$ cat blog/broken-access-control-idor.md
✓ vibe coding
✓ AI-generated code
✓ access control
✓ IDOR
8 min read
$ 

Ask an AI tool for "an invoices API with login" and you'll get a working one: sign up, sign in, create an invoice, list invoices, open an invoice. Everything works. Then a second user signs up, changes a number in the URL, and reads the first user's invoice.

That's broken access control, and it sits at the top of the OWASP Top 10. It's also the flaw that vibe-coded CRUD apps ship by default, because nothing about "it works" requires it to be absent. It's one of the recurring problems in the common security flaws of AI-generated apps.

Authentication is not authorization

Two different questions get blurred together:

  • Authentication: who are you? Answered once per request by a session or token.
  • Authorization: may you do this to this specific object? It has to be answered on every read, update and delete.

A requireLogin() check answers the first question only. It says "this is a real user", not "this is the owner of invoice 42". Most access-control bugs are code that is carefully authenticated and never authorized.

IDOR: the id in the URL decides everything

An insecure direct object reference (IDOR) is the simplest form: the server loads a record by the id the client sent and returns it. Here is the version a scaffold tends to produce, as a Next.js route handler:

// app/api/invoices/[id]/route.ts  (vulnerable)
export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const session = await auth();
  if (!session) return new Response("Unauthorized", { status: 401 });

  const { id } = await params;
  const invoice = await db.invoice.findUnique({ where: { id } });
  if (!invoice) return new Response("Not found", { status: 404 });

  return Response.json(invoice); // any signed-in user gets any invoice
}

The login check is there, so the code looks finished. But the query has no idea who is asking. The Express equivalent has the same shape: Invoice.findById(req.params.id) after an isAuthenticated middleware.

find by id only GET /invoices/42 (user B) where: { id: 42 } owner: user A (ignored) 200 + A's invoice scoped to session user GET /invoices/42 (user B) where: { id: 42, userId: B } owner: user A (no match) 404 not found
One invoice belongs to user A. User B sends A's id with B's own session. The vulnerable handler returns the data; the scoped query finds nothing and answers 404.

The fix is to make ownership part of the query itself, not a separate check you might forget:

// app/api/invoices/[id]/route.ts  (fixed)
export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const session = await auth();
  if (!session) return new Response("Unauthorized", { status: 401 });

  const { id } = await params;
  const invoice = await db.invoice.findFirst({
    where: { id, userId: session.user.id },
  });
  if (!invoice) return new Response("Not found", { status: 404 });

  return Response.json(invoice);
}

Two details are worth copying:

  • Ownership is part of the query. The where includes the owner, so a record you don't own simply isn't found. There's no code path where the data is loaded and then (maybe) withheld.
  • 404, not 403. A 403 tells the caller "that invoice exists, it's just not yours". A 404 for both "missing" and "not yours" confirms nothing. Use the same response, and ideally the same shape, for both.

Use the same pattern for update and delete. In Prisma, updateMany and deleteMany accept the owner in the filter and return a count, so you can return 404 when it's zero.

Never trust an owner id from the request

The second common shape is a mutation that takes the actor from the body:

// app/invoices/actions.ts (vulnerable: the client says who it is)
"use server";

export async function createInvoice(formData: FormData) {
  await db.invoice.create({
    data: {
      userId: String(formData.get("userId")),
      orgId: String(formData.get("orgId")),
      amount: Number(formData.get("amount")),
    },
  });
}

A hidden form field is still user input. Anyone can send a different userId or orgId and write into someone else's account. A server action is a public HTTP endpoint with a nice function signature, so it needs the same checks as any API route.

// app/invoices/actions.ts (fixed: identity comes from the session, never the payload)
"use server";

export async function createInvoice(formData: FormData) {
  const session = await auth();
  if (!session) throw new Error("Unauthorized");

  const { amount } = z
    .object({ amount: z.coerce.number().positive() })
    .parse(Object.fromEntries(formData));

  await db.invoice.create({
    data: { amount, userId: session.user.id, orgId: session.user.orgId },
  });
}

The rule: the client may say what it wants (the amount, the title). The server decides who is acting and where the data lives. If an orgId is allowed to vary, check that this user is a member of that org first.

Mass assignment: the body is not your model

When the update handler is generated for you, it often looks like this:

// vulnerable: whatever the client sends gets written
await db.user.update({
  where: { id: session.user.id },
  data: await req.json(),
});

It's short and it works for { "name": "Ada" }. It also works for { "role": "admin" }, { "isAdmin": true } or { "plan": "enterprise" }, because the spread has no idea which fields were meant to be editable. Any column on the model becomes writable from the outside.

The fix is an allowlist: a schema that names exactly what this endpoint may change, and ignores or rejects the rest.

const ProfileUpdate = UserSchema.pick({ name: true, avatarUrl: true }).strict();

const input = ProfileUpdate.parse(await req.json());
await db.user.update({
  where: { id: session.user.id },
  data: input, // only name and avatarUrl can ever reach the database
});

.strict() makes zod reject unknown keys instead of silently dropping them, which also surfaces client bugs. Privileged fields such as role and plan should only ever be changed by separate, separately authorized code paths: an admin endpoint, or a billing webhook.

Multi-tenant apps: scope in one place

If your app has organizations or workspaces, "owner" becomes "tenant". The failure mode is the same, but there are many more queries to get right, and one forgotten where is enough. Scattering orgId filters across handlers by hand means the third author, human or AI, will miss one.

Put the tenant in a shared data-access layer so a handler can't forget it:

// lib/data.ts: the only place handlers get a database handle
export function dbFor(session: { user: { id: string; orgId: string } }) {
  const orgId = session.user.orgId;
  return {
    invoices: {
      get: (id: string) => db.invoice.findFirst({ where: { id, orgId } }),
      list: () => db.invoice.findMany({ where: { orgId } }),
      create: (data: InvoiceInput) =>
        db.invoice.create({ data: { ...data, orgId } }),
    },
  };
}

Handlers call dbFor(session).invoices.get(id) and never touch the raw client. If you also use a hosted database with row-level policies, treat that as a second layer, not a replacement. Row-level security has its own ways to go wrong.

Why AI tools produce this

This isn't carelessness, it's what the prompt asked for. "Build an invoices CRUD with login" describes the happy path for one user. Ownership is business logic: whose invoices, which team, who may edit. If the prompt doesn't state it, the model has no reason to invent it, and the code passes every manual test because the person testing is always the owner. Generated code also copies its own patterns: once the first handler loads by id, the next ten do too.

Random IDs don't fix this

A common half-fix is to switch from sequential ids to UUIDs so nobody can guess the next one. That's worth doing, since it stops casual enumeration and doesn't leak your record counts. But it isn't authorization. UUIDs show up in lists, emails, logs, shared links and browser history. Anyone who obtains one can open the record unless the server checks ownership. Treat unguessable ids as defense in depth, never as the check.

How to check your own app

The most reliable test is a two-user test that lives in your test suite and runs on every change:

it("hides user A's invoice from user B", async () => {
  const a = await signInAs("[email protected]");
  const b = await signInAs("[email protected]");

  const created = await a.post("/api/invoices", { amount: 100 });
  const { id } = await created.json();

  expect((await b.get(`/api/invoices/${id}`)).status).toBe(404);
  expect((await b.patch(`/api/invoices/${id}`, { amount: 1 })).status).toBe(404);
  expect((await b.delete(`/api/invoices/${id}`)).status).toBe(404);

  // and A's record is untouched
  expect((await a.get(`/api/invoices/${id}`)).status).toBe(200);
});

Write one for every resource that belongs to a user or tenant. Then run through this checklist:

  1. Does every read, update and delete include the owner or tenant in the query, not just a login check?
  2. Does any handler or server action accept userId, orgId or ownerId from the request?
  3. Does any update spread req.body or form data straight into the database?
  4. Are privileged fields (role, isAdmin, plan, balance) writable only from dedicated, authorized paths?
  5. Do list endpoints filter by owner too, not only the single-item ones?
  6. Do "not yours" and "doesn't exist" return the same response?

Tell your AI tool up front

Put the rule in the project's rules file (CLAUDE.md, AGENTS.md, Cursor rules or equivalent) so every generated handler starts from it:

Access control rules:
- Every query for user- or tenant-owned data must filter by the session's
  user id / org id inside the query itself. Never load by id alone.
- Take identity (userId, orgId) from the server session, never from the
  request body, query string or form fields.
- Never spread request bodies into database writes. Validate with an
  explicit zod schema that lists the allowed fields (.pick(...).strict()).
- Return 404, not 403, when a record is missing or not owned by the caller.
- For each new resource, add a two-user test: user B must get 404 on
  user A's record for read, update and delete.

How OpenRouting helps

OpenRouting's access-control agent reviews whether each sensitive action is authorized for the specific object. That includes lookups by id with no ownership scope, identity taken from the request instead of the session, and updates built from unfiltered input. Each finding comes with a verdict, a plain-English explanation and a suggested fix, so you can tell a real IDOR from a route that is already scoped somewhere else.

You can run it on a full repo, or on each pull request: PR review reports findings in the changed files and posts one summary comment. It reads your code, so it won't replace the two-user test above. It points you at the handlers worth testing first.

Questions

What is an IDOR vulnerability?

An insecure direct object reference (IDOR) is when the server loads a record by the id the client sent and returns it without checking who is asking. Any signed-in user can change the id in the URL and read or edit another user's data.

How do I fix IDOR in a Next.js route handler?

Make ownership part of the query itself, for example filtering on both the id and the session's user id, so a record you don't own simply isn't found. Return 404 for both missing and not-yours records so the response confirms nothing.

Do UUIDs prevent IDOR attacks?

No. Unguessable ids stop casual enumeration, but UUIDs show up in lists, emails, logs, shared links and browser history. Anyone who obtains one can open the record unless the server checks ownership, so treat them as defense in depth, never as the check.

What is mass assignment and how do I prevent it?

Mass assignment is writing the request body straight into the database, so a client can set fields like role, isAdmin or plan. Prevent it with an allowlist schema that names exactly which fields the endpoint may change and rejects the rest.

Keep reading