Authorisation and Access Control

By Davy Rogers

Authentication says who you are. Authorisation says what you can do. One of them is broken everywhere.

Authentication and authorisation get said in the same breath, but they fail very differently. Authentication is who you are, and it's mostly a solved problem: you wire in a library and you're done. Authorisation is what you're allowed to do, and it's broken in application after application, because it can't be solved once and reused.

There are two structural reasons for that. Authentication happens in one place; authorisation has to happen on every single endpoint. And your framework hands you authentication middleware, whereas authorisation is business logic that only you can write, because only you know who's allowed to do what.

IDOR, the most common version

Insecure Direct Object Reference is what happens when your app trusts a user-supplied ID without checking the user owns the thing it points to.

GET /api/orders/4821

Does this user own order 4821? If nothing checks, any logged-in user can read any order by counting upward. The instinctive fix is to verify ownership after fetching:

order = Order.objects.get(id=order_id)
if order.user_id != request.user.id:
    raise PermissionDenied()

The better fix is to make the unauthorised row impossible to fetch in the first place, by scoping the query to the user:

order = Order.objects.get(id=order_id, user_id=request.user.id)

Now there's no window where you're holding someone else's data and relying on remembering to check.

Pick a model that fits

ScenarioModel
Simple app, fixed rolesRBAC (roles carry permissions)
Multi-tenant SaaSRBAC per tenant, or ReBAC
Complex enterprise rulesABAC (attributes of user, resource, environment)
Sharing and collaborationReBAC ("User X is an editor of document Y")

You don't need the fanciest model. You need the one that matches how access actually works in your product.

The mistakes that recur

Checking roles instead of permissions. Role checks scatter assumptions through the code and break the moment roles change.

# Fragile
if user.role == "admin":
    delete_user(target_id)

# Better
if user.has_permission("users:delete"):
    delete_user(target_id)

Enforcing on the client only. Hiding a button is a UX choice. The server still has to check, because anyone can call the endpoint directly.

Enforcing inconsistently. If GET checks ownership but PUT doesn't, an attacker modifies what they were never allowed to see.

Mass assignment. An update endpoint that accepts { "role": "admin" } and writes it straight through is a privilege escalation waiting to happen. Filter the fields you accept.

Security by obscurity. /api/admin/users is not protected by the fact that "only admins know the URL." Attackers find URLs.

Doing it well

Centralise the policy so the rules live in one auditable, testable place rather than being re-derived at every call site. Deny by default, so a new endpoint with no auth check fails closed instead of open. And prove it with tests that assert the negative case:

def test_user_cannot_access_other_users_order():
    order = create_order(user=user_a)
    response = client.get(f"/api/orders/{order.id}", auth=user_b)
    assert response.status_code == 404

Log the denials too. A run of sequential IDs all coming back 403 is somebody probing, and you want to see it.

The habit that ties it together: check ownership on every resource access, on the server, scoped to the authenticated user, and deny anything you didn't explicitly allow.