What is it? #
Authorization decides what an authenticated user is allowed to do. Authentication proves identity; authorization enforces limits.
The simplest model is roles: admin, editor, viewer. Each role carries a set of permissions, and each endpoint requires one.
Roles alone are rarely enough. Most real systems also need ownership checks — a user may edit their own order, not everyone's — and often resource-level rules.
The most damaging bug in this area is checking the role but forgetting the ownership check, which lets any logged-in user read other people's data by changing an ID in the URL.
Think of it like this #
A hotel keycard. Every guest has one, which proves they are a guest. It opens their own room and the gym, but not other rooms and not the staff office.
A card that opened every room would be worse than no card at all — everyone would assume they were safe when they were not.
Simple example #
An invoices endpoint. An admin may read any invoice. A regular user may read only their own. The role check alone is not enough, because the request includes an invoice ID the user controls.
Code #
from enum import Enum
from functools import wraps
class Permission(str, Enum):
INVOICE_READ_ANY = "invoice:read:any"
INVOICE_READ_OWN = "invoice:read:own"
INVOICE_DELETE = "invoice:delete"
USER_MANAGE = "user:manage"
ROLE_PERMISSIONS = {
"admin": {Permission.INVOICE_READ_ANY, Permission.INVOICE_DELETE, Permission.USER_MANAGE},
"staff": {Permission.INVOICE_READ_ANY},
"customer": {Permission.INVOICE_READ_OWN},
}
def permissions_for(user) -> set[Permission]:
return ROLE_PERMISSIONS.get(user.role, set())
def requires(permission: Permission):
def decorator(func):
@wraps(func)
def wrapper(user, *args, **kwargs):
if permission not in permissions_for(user):
raise PermissionError("not allowed")
return func(user, *args, **kwargs)
return wrapper
return decorator
# Role check AND ownership check — both are needed
def get_invoice(user, invoice_id: int):
invoice = invoices.find(invoice_id)
if invoice is None:
raise NotFound()
perms = permissions_for(user)
if Permission.INVOICE_READ_ANY in perms:
return invoice
if Permission.INVOICE_READ_OWN in perms and invoice.customer_id == user.id:
return invoice
# Return 404, not 403: do not confirm that this invoice exists
raise NotFound()
@requires(Permission.INVOICE_DELETE)
def delete_invoice(user, invoice_id: int):
invoices.delete(invoice_id)
-- Better still: scope the query itself, so the check cannot be forgotten
SELECT * FROM invoices
WHERE id = :invoice_id
AND (:is_staff OR customer_id = :user_id);
Models, from simple to flexible
role-based (RBAC) user has roles, roles have permissions
attribute-based (ABAC) rules over attributes: department, region, amount
ownership / relationship "is this user the owner, member or manager of it?"
policy engine centralised rules, useful in large systems
Most applications need RBAC plus ownership. Start there.
How it works #
Permissions are named as strings rather than checked by role directly. Checking user.role == "admin" scatters role names through your code; checking a permission means adding a role later is a configuration change.
The requires decorator handles the simple case: does this user hold this permission at all.
get_invoice shows the case that actually matters. Staff hold read:any and get the invoice. Customers hold read:own and get it only when the invoice belongs to them. Without that second condition, any customer could read any invoice by changing the ID — the vulnerability known as insecure direct object reference.
Raising NotFound rather than PermissionError for an unauthorised read is deliberate. A 403 confirms the resource exists, which leaks information. For data the user should not know about, 404 is the safer answer.
The SQL version is the most robust approach. Putting the ownership condition into the query means a developer cannot forget the check, because the row is never fetched in the first place. Some databases go further with row-level security policies.
Authorization must be enforced on the server for every request. Hiding a button in the interface is a usability improvement, not a security control.
Real-world use #
Broken access control is consistently ranked as the most common serious web vulnerability, and it is almost always the missing ownership check rather than a missing login.
Multi-tenant systems make it sharper still. Every query must be scoped by tenant, and the standard defence is enforcing that at the data access layer rather than trusting each endpoint.
Admin interfaces are a frequent weak point, because they are built quickly and assumed to be behind a login. Role checks belong there as much as anywhere.
Tests are the practical safeguard. For each endpoint, write a test asserting that another user's ID returns 404, and that a lower-privileged role is refused. Those tests catch regressions that code review misses.
Common mistakes #
- Checking the role but not whether the user owns the resource.
- Enforcing permissions only in the user interface.
- Returning 403 for resources the user should not know exist, leaking their existence.
- Hardcoding role names throughout the codebase instead of named permissions.
- Forgetting tenant scoping in one query in a multi-tenant system.
Practice #
Write an endpoint returning a document by ID with three rules: admins see all, members see documents in their team, everyone else gets 404. Then write three tests — admin, member with a foreign document, and a stranger — and confirm the responses.