Authentication answers "who are you?" Authorisation answers "what are you allowed to do?" Getting authorisation wrong means users can access, modify, or delete data that does not belong to them. This is consistently one of the most common and most impactful vulnerability classes.
Why authorisation bugs are so common
- Authentication is usually centralised (one login system). Authorisation is distributed (every endpoint, every query, every feature has its own access rules).
- Frameworks provide authentication middleware. They rarely provide authorisation middleware — you have to build it yourself.
- Authorisation logic is business logic. It varies per feature, per role, per resource, and per context. It cannot be solved with a generic library.
IDOR — insecure direct object references
The simplest and most common authorisation bug. The application uses a user-controlled identifier (ID in the URL, request body, or query parameter) to fetch a resource, without checking whether the requesting user owns or is allowed to access that resource.
Example
GET /api/orders/4821
The server returns order 4821. But does the authenticated user own order 4821? If the server does not check, any authenticated user can access any order by changing the ID.
Fix
Always verify ownership or permission before returning data:
order = Order.objects.get(id=order_id)
if order.user_id != request.user.id:
raise PermissionDenied()
Better yet, scope your queries:
# Only fetch orders belonging to the current user
order = Order.objects.get(id=order_id, user_id=request.user.id)
If the order does not exist for that user, it returns a 404 — which also avoids leaking that the order exists at all.
Access control models
Role-Based Access Control (RBAC)
Users are assigned roles. Roles have permissions. The check is: "does this user's role allow this action?"
admin → can create, read, update, delete
editor → can create, read, update
viewer → can read
RBAC is straightforward and works well for applications with a fixed set of roles and permissions. It breaks down when you need fine-grained, resource-level, or context-dependent access control.
Attribute-Based Access Control (ABAC)
Access decisions are based on attributes of the user, the resource, and the environment:
- User attributes: role, department, clearance level
- Resource attributes: owner, classification, sensitivity
- Environment attributes: time of day, IP address, device type
Example policy: "A user can edit a document if they are in the same department as the document owner AND the document is not classified as restricted."
ABAC is more flexible than RBAC but harder to reason about and audit. It is best suited for complex enterprise environments.
Relationship-Based Access Control (ReBAC)
Access is determined by the relationship between the user and the resource. Think Google Docs: "this user is an editor of this document" rather than "this user has the editor role."
ReBAC handles hierarchical and graph-shaped permission models well. Tools like Zanzibar (Google), SpiceDB, and OpenFGA implement this model.
Choosing a model
| Scenario | Recommended model |
|---|---|
| Simple app, few roles | RBAC |
| Multi-tenant SaaS | RBAC per tenant, or ReBAC |
| Complex enterprise with contextual rules | ABAC |
| Collaboration features (sharing, ownership) | ReBAC |
Most applications start with RBAC and evolve. That is fine — just plan for the evolution.
Common authorisation mistakes
Checking roles instead of permissions
# Fragile — tied to role names
if user.role == "admin":
delete_user(target_id)
# Better — tied to permissions
if user.has_permission("users:delete"):
delete_user(target_id)
Roles change. Permissions are more stable. Check permissions, not roles.
Client-side only enforcement
Hiding a button in the UI is not access control. If the API endpoint behind that button does not check permissions, the attacker will call it directly.
Every authorisation check must be enforced on the server. Client-side visibility is a UX convenience, not a security control.
Inconsistent enforcement
The /api/orders/:id GET endpoint checks ownership. The /api/orders/:id PUT endpoint does not. An attacker cannot view another user's order but can modify it. Consistency across all operations on a resource is essential.
Mass assignment enabling privilege escalation
If an API accepts arbitrary fields in a request body and applies them to a model without filtering:
PUT /api/users/me
{ "name": "Alice", "role": "admin" }
The user promotes themselves to admin. Always define an explicit allowlist of updatable fields.
Broken function-level authorisation
Admin endpoints like /api/admin/users often lack authorisation checks because "only admins know the URL." Obscurity is not security. Every endpoint must enforce its own access controls.
Implementing authorisation well
Centralise the policy
Define authorisation rules in one place, not scattered across individual route handlers. This makes the rules auditable, testable, and consistent.
# A central permission check
def check_permission(user, action, resource):
if action == "orders:read" and resource.user_id == user.id:
return True
if action == "orders:read" and user.role == "admin":
return True
return False
Deny by default
Start with "deny everything" and explicitly grant access. If a new endpoint is added without an authorisation check, it should fail closed (deny access), not fail open.
Test authorisation explicitly
Write tests that verify:
- User A cannot access User B's resources
- A regular user cannot access admin endpoints
- Each role can only perform its permitted actions
- Downgrading a role immediately removes access
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
Audit log access control decisions
Log when access is denied and when high-privilege actions are taken. If an attacker is probing for IDOR vulnerabilities, the denied-access logs will show a pattern of sequential ID access attempts.
Summary
Authorisation is the most frequently broken access control in web applications because it is distributed, business-specific, and easy to forget. Check ownership on every resource access. Use permissions instead of role names. Enforce everything on the server. Deny by default. Scope queries to the authenticated user. Test authorisation paths as rigorously as you test functionality.
