Frameworks fixed injection. Not perfectly, but well enough that in a modern codebase you have to work to write a query that concatenates user input. Parameterisation is the default, templating escapes by default, and the ugly cases are rare enough to be interesting.
Nothing did that for authorisation, because authorisation is not a syntax problem. There is no safe-by-default way to express 'this invoice belongs to this company and the current user is allowed to see that company's invoices'. That sentence is business logic, and business logic has to be written by hand, in every handler, forever.
The shape it takes
Almost every access control bug I find is the same mistake in a different costume: the code checks that you are someone, and forgets to check that you are the right someone.
// authenticated — and completely unauthorised
export async function GET(req: Request, { params }: Ctx) {
const user = await requireSession(req); // are you logged in? yes
return json(await db.invoice.findUnique({ // is it yours? never asked
where: { id: params.id },
}));
}The fix is not a middleware. It is making the ownership part of the lookup, so that forgetting it returns nothing instead of returning someone else's data.
const invoice = await db.invoice.findFirst({
where: { id: params.id, org: { members: { some: { userId: user.id } } } },
});
if (!invoice) return notFound(); // same response for absent and forbiddenHow I look for it
- 01Create two accounts in the lowest-privileged role available, in two different tenants.
- 02Drive the whole application as account A with the proxy recording. This is the enumeration step — every id that appears is a candidate.
- 03Replay each request as account B, changing only the identifier. Not the session, not the role: the identifier.
- 04Then replay them with account B's session against account A's identifiers, and again with no session at all.
- 05Anything that answers with data instead of an error goes in the report.
The reason this works is boring: the check that is missing is almost never missing everywhere. It is missing on the endpoint that was added last, under deadline, by someone who copied the handler above it and deleted the part they did not understand.
Where to look first
- Export, download and print routes — added late, reviewed less, and they return everything.
- Anything with a numeric id in a product that otherwise uses UUIDs. Someone reached for the database key.
- Bulk endpoints. The single-object route is checked; the array version filters nothing.
- State transitions. Reading may be guarded while the action that changes the object is not.
- Admin functionality reachable by direct request after the navigation link is hidden.
Authentication asks who you are. Authorisation asks what that entitles you to. Only one of them has a default.
