Explainer

Broken access control: the bug AI writes most confidently

Why AI-generated endpoints check login but not ownership, the four forms of broken access control, a five-minute test, and patterns that fix it for good.

VibeTect Resources3 min read

Key takeaways

  • 01Broken access control is the top category in the OWASP Top 10 (2021), and AI-generated APIs are especially prone to it.
  • 02Authentication answers “who are you?”; authorization answers “are you allowed to do this?”. AI code often handles only the first.
  • 03The two-account test finds most insecure direct object references in minutes.
  • 04Fix it structurally: scope every query by owner and centralize authorization rules.

Ask an AI assistant to “add an endpoint that returns an order by ID” and you’ll usually get clean, working code. It reads the ID from the URL, looks up the order, and returns it. It may even check that the user is signed in.

What it often won’t do is check that the order belongs to the user asking for it. That missing line is broken access control — the category ranked first in the OWASP Top 10 (2021).

Authentication is not authorization

The confusion starts with two words that sound similar:

  • Authentication answers who are you? — usually by verifying a session or token.
  • Authorization answers are you allowed to do this, to this specific thing?

Most AI-generated routes handle authentication well, because frameworks make it easy and prompts often mention it (“only for logged-in users”). Authorization depends on your application’s rules — who owns what, which roles can do which actions — and those rules usually aren’t in the prompt.

Four forms it takes

1. Insecure direct object references (IDOR)

A record is fetched or changed using an identifier from the request, with no ownership check.

export async function GET(req: Request, ctx: Ctx) {
  const session = await getSession(req);
  if (!session) return new Response(null, { status: 401 });

  const order = await db.order.findUnique({
    where: { id: ctx.params.id },
  });
  return Response.json(order);
}

Any signed-in user can change the ID and read any order. Sequential IDs make this trivial; random IDs make it harder to guess but don’t fix it — IDs leak through URLs, logs, emails and shared links.

2. Missing function-level checks

An admin feature is hidden in the interface, but the endpoint behind it only checks that someone is signed in. Anyone who finds the URL — by reading the frontend JavaScript, for instance — can call it.

3. Mass assignment

The whole request body is passed into an update:

await User.findByIdAndUpdate(req.params.id, req.body);

Now a user can send { "role": "admin" } or { "plan": "enterprise" } along with their name change.

4. Client-side-only enforcement

The frontend decides what to show, and the backend trusts it. This includes apps that query a database directly from the browser without strict row-level rules — the pattern behind many exposed Supabase and Firebase databases. See our guide to Supabase Row Level Security.

The five-minute test

You don’t need security tooling to find most IDOR bugs:

  1. Create two accounts, A and B.
  2. Signed in as A, open something that belongs to A — an order, invoice, document or profile. Note the URL, or copy the API request from your browser’s developer tools.
  3. Sign in as B and repeat the exact request.
  4. Try changing data too: send A’s update request while signed in as B.

If B can see or change A’s data, you’ve found broken access control. Repeat for every type of resource in your app.

Fixing it for good

Scope every query by owner

The most reliable fix puts the ownership condition inside the query itself, so there’s no code path that forgets it:

const order = await db.order.findFirst({
  where: { id: ctx.params.id, ownerId: session.userId },
});
if (!order) return new Response(null, { status: 404 });

Returning 404 rather than 403 avoids confirming which IDs exist.

Centralize authorization rules

Instead of repeating checks in every route, define them once — for example a canAccessOrder(user, order) helper, a policy layer, or database row-level security — and call it everywhere. When rules live in one place, they can be reviewed and tested in one place.

Allow-list writable fields

For updates, copy only the fields a user is permitted to change:

const { name, avatarUrl } = await req.json();
await db.user.update({ where: { id: session.userId }, data: { name, avatarUrl } });

Deny by default

New routes should be private unless deliberately made public, and role checks should fail closed. If your framework supports middleware, enforce authentication there and authorization in the data layer.

Tell your assistant the rule

When prompting, state the authorization requirement explicitly: “Only the order’s owner may read or update it; admins may read any order.” You’ll get dramatically better code — and a clear standard to review it against.

Catching it before it ships

Access-control bugs are hard to spot in code review because nothing looks wrong — the missing line simply isn’t there. Automated inspection helps by flagging lookups that use request identifiers without an ownership condition and updates that accept unfiltered request bodies. VibeTect is built to catch access-control gaps like these locally, alongside exposed secrets and unsafe authentication, so you can fix them before anyone else finds them.

Check it before you ship it.

Run a local security inspection on the project you’re about to deploy — and fix what it finds while it’s still cheap to fix.

Local analysis · No repository access required