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.
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:
- Create two accounts, A and B.
- 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.
- Sign in as B and repeat the exact request.
- 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.