14.7 Authorization Best Practices
Table of Contents
Principle Based Overview
Authorization decides who can do what in your system. Good authorization design is about clear rules, least privilege, and predictable behavior under both normal use and attack.
You will meet specific mechanisms like roles, permissions, and ownership in other chapters. Here we focus on how to apply them safely and consistently.
Core authorization rule
Never trust the client. Always enforce authorization checks on the server for every protected action or resource.
We will walk through patterns, mistakes to avoid, and practical examples you can use in any backend stack.
Separate Authentication and Authorization
Authentication answers “Who are you?”. Authorization answers “What are you allowed to do?”. Keep them separate in both code and architecture.
Why separation matters
If you mix them, you tend to:
- allow unauthenticated actions by mistake
- give permissions just because a user is “logged in”
- make it hard to change roles or permissions later
Instead:
- Authentication creates an identity context: user id, roles, maybe groups.
- Authorization uses that context to check access to specific resources or operations.
Example identity context
For a request you might build a context object:
class AuthContext:
def __init__(self, user_id: int, roles: list[str], permissions: list[str], is_admin: bool):
self.user_id = user_id
self.roles = roles
self.permissions = permissions
self.is_admin = is_adminEvery protected endpoint:
- Authenticates the user.
- Builds
AuthContext. - Passes it into authorization checks.
Do not put database queries or business logic inside your JWT or session creation step. Keep that for authorization layers.
Enforce Server Side Checks
Clients are untrusted. They can:
- modify JavaScript in the browser
- send any HTTP request they want
- forge or alter data, headers, or bodies
So your server must never rely on:
- hidden form fields
- disabled buttons in the UI
- grayed out menu items
- client side validations only
Example of bad vs good
Bad approach:
- UI hides “Delete” button for normal users.
- API endpoint
/admin/delete-userchecks nothing. - An attacker calls the endpoint manually and deletes accounts.
Good approach:
- UI also hides the “Delete” button, but only for usability.
- API endpoint checks
auth_context.is_adminorhas_permission("user.delete"). - If check fails, return
403 Forbidden.
Rule
Protect every sensitive endpoint with a server side authorization check, even if the UI never shows it to normal users.
Use Least Privilege
Least privilege means: give users only the permissions they really need, and no more.
Practical least privilege guidelines
- Default role: a minimal role like
userthat has the smallest set of permissions. - Admins: create specific admin roles, not a single “god mode” that can do everything.
- Service accounts: for internal services, give them only what they need, for example, only
order.readandorder.update-status.
Example permission sets
| Role | Permissions example |
|---|---|
user | task.read_own, task.create, task.update_own |
manager | All user + task.read_team, task.update_team |
admin | user.read_all, user.disable, task.read_all |
Do not assign admin just because someone “might” need it in future. Promote only when there is a real requirement.
Prefer Centralized Authorization Logic
If you put checks everywhere, in random helper functions and controllers, it quickly becomes impossible to reason about who can do what.
Instead, centralize your authorization logic.
Where to centralize
You can centralize via:
- a dedicated authorization service or module
- reusable policy functions
- decorators or middleware for common patterns
Example: policy functions
from enum import Enum, auto
class Action(Enum):
READ = auto()
UPDATE = auto()
DELETE = auto()
def can_access_task(user, task, action: Action) -> bool:
if user.is_admin:
return True
if action == Action.READ:
return task.owner_id == user.id or task.assignee_id == user.id
if action in (Action.UPDATE, Action.DELETE):
return task.owner_id == user.id
return FalseThen in your route:
if not can_access_task(auth_context.user, task, Action.UPDATE):
raise HTTPException(status_code=403)This keeps all task related authorization rules in one place and much easier to review and test.
Prefer “Deny by Default”
The safest default is no access unless explicitly allowed.
Design for explicit allow
- When you add a new endpoint, assume no roles can use it yet.
- Add checks to allow access for specific roles or permissions.
- If the check is missing, it should fail closed, not open.
Example pattern
def require_permission(user, permission: str):
if permission not in user.permissions:
raise HTTPException(status_code=403, detail="Forbidden")Use this in your endpoints:
@app.post("/admin/create-user")
def create_user(auth=Depends(get_auth_context)):
require_permission(auth.user, "user.create")
...
If you forget to call require_permission, that is a bug, but you design tests and patterns so this is obvious.
Rule
Fail closed, not open.
If something goes wrong in authorization, the request should be rejected, not granted.
Validate Ownership and Resource Level Access
Checking that someone has the right role is not enough. For many operations, you must also check that the resource actually belongs to them or to their scope.
Example: user reading a task
We have a user role that can read only their own tasks.
Bad version:
# /tasks/{task_id}
def get_task(task_id, auth=Depends(get_auth_context)):
task = db.get_task(task_id)
if "task.read" not in auth.permissions:
raise HTTPException(403)
return task
This allows any user with task.read permission to read any task.
Better version with ownership:
def get_task(task_id, auth=Depends(get_auth_context)):
task = db.get_task(task_id)
if auth.is_admin:
return task
if task.owner_id != auth.user_id:
raise HTTPException(status_code=404) # or 403
return taskNow users can see only their own tasks, and admins can see all.
Using 403 vs 404
403 Forbiddenmeans “I know this resource exists but you cannot access it”.404 Not Foundhides the existence of the resource entirely.
For security, many systems choose to return 404 for unauthorized resource access to avoid information leaks.
Use Consistent Rules for Collections and Single Resources
A common subtle bug:
- Listing endpoint filters by ownership.
- Detail endpoint does not.
Example:
# List tasks: correct filtering
GET /tasks
-> returns only tasks where owner_id = current_user.id
# Single task: missing ownership check
GET /tasks/{id}
-> returns the task without checking ownerThis makes enumeration attacks easier. Consistency is key.
Good pattern
For collections and single resource endpoints:
- Use the same authorization rule, derived from the same policy.
- If the list is “tasks visible to the user”, then the detail endpoint should only show tasks from that same set.
Example
def list_tasks(auth):
return db.list_tasks_visible_to_user(auth.user_id)
def get_task(task_id, auth):
task = db.get_task(task_id)
if not db.is_task_visible_to_user(task_id, auth.user_id):
raise HTTPException(404)
return taskAvoid Client Controlled Authorization Data
Never base access on anything that comes from the client if you can derive it from the server.
Bad ideas:
- The client sends
user_rolein the body. - The client sends
is_admin: truein JSON. - The client sends
owner_idto indicate who owns a resource.
Server must determine authorization data
Instead:
- Extract the user id and roles from a signed token or server side session.
- Determine ownership based on the database and server side logic.
Example: create task
Bad:
def create_task(payload, auth):
# client sends owner_id they want
task = Task(
title=payload["title"],
owner_id=payload["owner_id"],
)
db.save(task)Any user could create tasks on behalf of others.
Good:
def create_task(payload, auth):
task = Task(
title=payload["title"],
owner_id=auth.user_id, # server decides
)
db.save(task)Be Careful With “Admin” and “Superuser”
Admin accounts are powerful and attractive targets.
Best practices for admin privileges
- Keep admin users separate from normal users where possible.
- Use multi factor authentication for admin accounts.
- Log every admin action.
- Avoid a single “superuser” in production. Use smaller scoped admin roles, for example, “support admin”, “billing admin”.
Example: separating admin endpoints
Use a clear URL prefix and separate authorization checks:
@app.get("/admin/users")
def list_users(auth=Depends(get_auth_context)):
require_permission(auth.user, "admin.view_users")Do not give normal users access to admin endpoints even if they have similar permissions.
Design Clear Roles and Permissions
You will see role based and permission based models in their own chapters. Here, focus on how to design them cleanly.
Keep roles and permissions understandable
Good patterns:
- Keep permission names action.resource, for example,
user.create,order.cancel. - Avoid very generic permissions like
allor*. - Avoid too many roles that differ only slightly.
Example mapping
| Permission | Description |
|---|---|
task.read_own | Read tasks where the user is owner |
task.read_all | Read all tasks |
task.create | Create new tasks |
task.update_own | Update own tasks |
task.update_all | Update any task |
Roles:
| Role | Permissions |
|---|---|
user | task.read_own, task.create, task.update_own |
admin | all above + task.read_all, task.update_all |
Clear naming helps review, audits, and tests.
Protect Sensitive Operations More Strictly
Some actions are more dangerous than others. These should have extra checks beyond normal authorization.
Examples:
- changing email or password
- deleting an account
- performing high value financial operations
- changing access levels or roles for others
Extra protection ideas
- Require recent authentication, for example re enter password.
- Require 2FA confirmation.
- Require stronger roles or explicit permissions.
- Log and alert on these operations.
Example: changing password
Even if user has user.update_profile permission, you might still require a password confirmation:
def change_password(old_password, new_password, auth):
if not verify_password(old_password, auth.user.hashed_password):
raise HTTPException(403, "Invalid password")
# continue with password changeAvoid Security Through Obscurity
Hiding endpoints or identifiers does not replace proper authorization.
Weak approaches:
- “No one knows the URL, so we do not need checks.”
- “IDs are UUIDs, so users cannot guess them.”
UUIDs and secret URLs can reduce random guessing, but:
- attackers can still discover APIs from JavaScript files or traffic
- link sharing or logs can reveal “secret” URLs
Rule
Obscurity is not authorization. Always enforce checks regardless of how hard the resource is to guess.
Use Proper HTTP Status Codes
Consistent responses help both clients and security reviewers.
Basic guidelines:
- Use 401 Unauthorized when the user is not authenticated or the token is invalid.
- Use 403 Forbidden when authenticated but not allowed.
- Use 404 Not Found when you want to hide resource existence for unauthorized users.
Example table
| Situation | Status |
|---|---|
| No token or invalid token | 401 |
| Valid token but missing permission | 403 |
| Resource does not exist at all | 404 |
| Resource exists but user should not know | 404 or 403 (depending on policy) |
Return helpful but not over specific error messages, for example:
{
"detail": "Forbidden"
}Do not leak internal role names or permission systems in error messages.
Avoid Over Granting on Login or Registration
Be careful when users first join your system.
Common mistakes:
- giving new users admin or staff roles for testing and forgetting to remove them
- elevating permissions automatically based on email domain, for example
@company.com=> admin
Better:
- New users get a minimal role.
- Admins explicitly upgrade them through a safe, authenticated admin flow.
Example: safe default
def register_user(email, password):
user = User(
email=email,
hashed_password=hash_password(password),
role="user" # minimal permissions
)
db.save(user)Be Mindful With “Remember Me” and Long Lived Tokens
Long lived sessions and tokens increase the window in which stolen credentials can be used.
Authorization best practice here means:
- Shorter access token lifetimes.
- Use refresh tokens for longer lived sessions.
- Store refresh tokens securely and revocable on logout or suspicion.
- Reduce what long lived tokens can do, for example no admin operations.
Example policy
- Access token lifetime: 15 minutes.
- Refresh token lifetime: 7 days.
- Re authentication required for admin actions or sensitive changes, even if token is valid.
Rate Limit and Monitor Sensitive Authorization Paths
Even with correct authorization, attackers may attempt:
- role escalation
- repeated attempts to access forbidden resources
- abuse of endpoints that change permissions
Best practice:
- Add rate limiting to authorization relevant endpoints, for example login, role change, password reset confirm.
- Log denied access attempts with enough context: user id, endpoint, time.
- Set up alerts for unusual patterns like many 403s from a single IP.
Example logs to keep
| Field | Example |
|---|---|
| user_id | 123 |
| endpoint | POST /admin/change-role |
| outcome | denied or granted |
| reason | missing permission: role.update |
| correlation_id | request id for tracing |
| timestamp | 2026-08-27T12:34:56Z |
Keep Authorization Data in Sync With Business Rules
Business rules change over time:
- new features
- policy changes
- regulations
Authorization rules must be updated together with business logic.
Good practice
When implementing a new feature:
- Define what roles or users can do with it.
- Add corresponding permissions or policies.
- Add tests that enforce those rules.
- Update documentation for admins and developers.
Example: new “archive task” feature
Decide:
- Can normal users archive only their own tasks?
- Can admins archive any task?
- Are there tasks that must never be archived?
Then encode that in a policy function:
def can_archive_task(user, task) -> bool:
if user.is_admin:
return True
return task.owner_id == user.idAnd test it explicitly.
Test Authorization Thoroughly
Authorization bugs are easy to miss and very dangerous. You should test them systematically.
Types of tests
- Unit tests for policy functions, for example
can_access_task,require_permission. - Integration tests for endpoints that try combinations of user roles, resource ownership, and actions.
Example test matrix
For endpoint: DELETE /tasks/{id}
| User type | Own task | Others task | Expected result |
|---|---|---|---|
user | yes | no | 204 / success |
user | no | yes | 404 or 403 |
admin | yes | yes | 204 / success |
| anonymous | any | any | 401 |
Automate these tests and run them in CI to prevent regressions.
Rule
Every critical authorization rule should have at least one automated test case.
Document Your Authorization Model
Future you and other developers need to understand how your system decides access.
Good documentation includes:
- Description of roles and their permissions.
- Examples of who can do what for important resources.
- Notes about special cases, for example, “support staff can temporarily impersonate users, but only with logging.”
Example documentation snippet
user- manage own tasks: create, view, update, delete
- view own profile
manager- everything
usercan do - view and update tasks for team members
admin- manage all users and tasks
- cannot see user passwords
- cannot perform actions without being logged with 2FA
Keep documentation near your code or in your API docs. When rules change, update both code and docs together.
Summary
Authorization best practices are about:
- checking access on the server every time
- giving users only what they need, by default
- validating ownership and resource scope, not just roles
- centralizing and testing your rules
- designing for secure defaults and clear behavior
Once you internalize these principles, you can combine any specific model, role based, permission based, or attribute based, and still keep your backend safe and maintainable.
Views: 8
KAHIBARO