Why authorization bugs outlive authentication bugs
Authentication is a single gate you can test once. Authorization is a decision repeated at every endpoint, and that asymmetry is why broken access control keeps topping the charts.
- Filed under
- Application Security
- Published
- Reading time
- 3 min
Authentication failures are loud. A broken login is a broken product, so it gets found early, tested hard, and fixed fast. Authorization failures are quiet. The request succeeds, the response looks normal, and nothing in the logs suggests the caller was never supposed to see that record.
That asymmetry is most of the story behind why broken access control keeps ranking as the most common serious class of web vulnerability.
Authentication is one gate, authorization is a thousand
Authentication answers one question once per session: who is this? You can centralize it in middleware, test it in isolation, and be reasonably confident it holds.
Authorization answers a different question on every single request: is this caller allowed to do this specific thing to this specific object? The number of places that decision has to be correct grows with every endpoint, every field, and every relationship in the data model.
A codebase with one login flow might have four hundred authorization decisions. Only one of them has to be wrong.
The default is usually "allow"
Most authorization bugs are not wrong logic. They are absent logic.
A developer adds a route that loads a record by ID, wires it to the ORM, and ships. The route inherits authentication from middleware, so it is not unauthenticated. It simply never checks whether the authenticated user has any relationship to the record being loaded.
# The bug is not in this line. The bug is that no other line exists.
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).one()
Nothing about this code looks wrong in review. It looks like every other data-fetching line in the file. The missing check is invisible in a way that a missing authentication check is not, because there is no error, no redirect, and no failing test.
Object scoping beats endpoint checks
The durable fix is to make the unscoped query impossible to write, rather than to remember a check at every call site.
If the data access layer requires an actor to construct a query, the absence of an authorization decision becomes a compile-time or startup-time failure instead of a silent runtime permission grant:
# The actor is not optional. There is no way to ask for an invoice
# without also saying who is asking.
invoice = invoices.for_actor(current_user).get(invoice_id)
This is the same principle as parameterized queries. SQL injection did not stop being common because developers got better at escaping strings. It became rare in modern codebases because the ergonomic path stopped requiring escaping at all.
Authorization needs the same treatment: the convenient way to load data should be the scoped way.
Test the negative case
Access control tests usually assert that the right user can reach the resource. That test passes whether or not authorization exists.
The test that actually has value is the one asserting that a different, equally valid user cannot:
def test_other_tenant_cannot_read_invoice(client, invoice, other_user):
response = client.get(f"/invoices/{invoice.id}", user=other_user)
assert response.status_code == 404
Returning 404 rather than 403 is deliberate here. A 403 confirms the record exists, which leaks the object's existence to anyone probing IDs. If the caller has no relationship to the object, the honest answer is that, as far as they are concerned, it is not there.
What this means in review
When reading a diff, authentication problems announce themselves. Authorization problems require asking a question that the code does not prompt you to ask:
For every new query in this change, what stops a valid user from passing an ID they do not own?
If the answer is "the route it lives behind," that is not an answer. Routes get reused, refactored, and exposed through new callers. The scoping has to live with the data access, not with the URL.
Authentication is a door. Authorization is every drawer in the building, and nobody hears when one opens.
