Most AI-generated APIs aren't broken because the code is wrong. They're broken because the code is missing a single line — the one that asks 'is this actually your data?' The route works perfectly for the user who built the app. Then a second user shows up.
Access control decides who is allowed to do what. Broken access control means the rules are missing or wrong — usually missing entirely on a route that 'works' because the only person who tested it was the owner. Every other user gets the same answer the owner did.
What your AI actually built
You asked for a CRUD API for invoices, orders, profiles, projects — pick one. The model gave you exactly that. A clean route that fetches a record by id and returns it as JSON. Tested it, worked, shipped it.
What it didn't give you was the ownership check. The route happily returns the row whether the requester owns it or not, because nothing in the prompt said 'and only let the right user read it.' That's not a code bug. That's a missing requirement the model couldn't infer.
On Supabase apps, the same pattern shows up as a missing Row Level Security policy. The table is created, the policy slot is empty, and every authenticated user can read every row. Same bug, different layer.
How it gets exploited
Two accounts. The attacker signs up like a normal user, then opens the network tab.
- 1Watch one requestThey log in, click their own invoice, and notice the URL: /api/invoices/417.
- 2Change one numberThey edit 417 to 416. The server returns somebody else's invoice — full JSON, no error.
- 3Walk the rangeA 20-line script enumerates 1 through 5000. Every record comes back. Every customer.
- 4PivotThe same pattern works on /api/users, /api/orders, /api/uploads. The bug is structural, not local.
The attacker now has the entire user database, every invoice, and every uploaded file — without a single password, exploit, or unusual request. The logs show only authenticated traffic.
Vulnerable vs Fixed
// app/api/invoices/[id]/route.ts
export async function GET(req, { params }) {
const invoice = await db.invoice.findUnique({
where: { id: params.id },
});
// Whoever asks gets the row.
return Response.json(invoice);
}// app/api/invoices/[id]/route.ts
export async function GET(req, { params }) {
const session = await getSession(req);
const invoice = await db.invoice.findUnique({
where: {
id: params.id,
userId: session.user.id, // ownership lives in the query
},
});
if (!invoice) {
return Response.json({ error: 'Not found' }, { status: 404 });
}
return Response.json(invoice);
}One extra clause in the where. That is the entire fix. The 404 instead of 403 is intentional — never confirm whether a record exists when the user is not allowed to see it.
A real case
170 Lovable apps leaked their entire users tables
Researchers walked into 170 production Supabase apps via the anon key because RLS was never enabled — the same Broken Access Control pattern, one layer down.
Read the case studyRelated reading
References
Find every Broken Access Control bug in your app.
Flowpatrol logs in as multiple users and cross-tests every endpoint. Five minutes. One URL.
Try it free