Access control is not a UI problem
Working through the access-control labs changed how I read my own code. The pattern behind almost all of them is the same, and I had shipped it more than once.
- #access-control
- #owasp
- #labs
I came to security from building things. That turns out to matter, because the first category of bug that really landed for me was the one I recognised from my own repositories.
Broken access control sits at the top of the OWASP list, and after working through a run of labs on it the reason is obvious: it is not a subtle class of bug. It is a default — the thing that happens when nobody writes the check, because writing the check is optional and shipping the feature is not.
The shape
Almost every lab in the category reduces to the same three lines of reasoning:
- The interface only shows you what you are allowed to do.
- The server assumes that, because the interface enforced it, the request must be legitimate.
- There is no interface between you and the server.
That third point is the whole thing. Hiding the admin link does not remove the
admin route. Disabling the button does not stop the POST. Filtering the list
in the client means the rows were still sent.
What I found in my own code
Reading back through a project of mine, I found this:
export async function GET(req: Request, { params }) {
const order = await db.order.findUnique({ where: { id: params.id } })
return Response.json(order)
}The interface never links to an order you do not own, so in practice nobody had ever fetched somebody else's. That is not a control. That is a coincidence holding until someone increments an ID.
The fix is not a check bolted on top:
const order = await db.order.findUnique({
where: { id: params.id, userId: session.userId },
})
if (!order) return new Response(null, { status: 404 })Two things changed. The ownership condition is part of the query, not a
separate if that a future refactor can drop. And the failure returns 404
rather than 403, so the endpoint does not confirm that the ID exists.
The habit that came out of it
When I write a handler now, the first line resolves the actor and the second scopes the query to them. If those two lines are not at the top, the handler is not finished — regardless of what the interface does or doesn't show.
Where it gets less obvious
The tidy version above covers direct object references. The labs that took me longest were the ones where authorization was present but positional:
- Multi-step flows. Step three trusts that step two ran. Skip straight to three and the check never happened. The fix is a server-side state machine, not a hidden field carrying the step number.
- Role changes mid-session. A downgraded user keeps a token minted while they were an admin. Either the token is short-lived, or privilege is looked up per request rather than carried in the token.
- Referrer-based routing. Any check reading a header the client controls is a suggestion, not a boundary.
Why this is the one to learn first
Injection and XSS have well-known shapes and decent tooling — a scanner has a reasonable chance of finding them. Access control does not, because the tool cannot know that this user should not see that record. It is business logic, and it is therefore almost entirely on the person writing the endpoint.
Which is the argument for learning it from the builder's side first. I am not better at finding these bugs than a scanner because I know more about security. I am better at it because I know exactly which corner an ordinary developer cuts when the feature is due.