What authorization is (and what it is not)
Authentication answers who the caller is; authorization answers what they are allowed to do. In Flask services, authorization should be an explicit layer that can be applied consistently across routes and services. A practical authorization layer typically combines:
- Role-based checks (e.g.,
admin,support,member) - Resource ownership checks (e.g., user can edit their own profile, but not others)
- Policy functions that encode business rules (e.g., “can publish only if document is approved”)
The goal is to keep permission logic out of route handlers so routes remain thin: parse input, call a service, return a response.
Designing a clear authorization layer
1) Define permissions as stable identifiers
Prefer explicit permission strings over scattered role checks. Roles can map to permissions, but your code should usually ask “does the actor have permission X?” rather than “is the actor role Y?”.
# permissions.py (example module name; place where it fits your project layout) PERM_PROJECT_READ = "project:read" PERM_PROJECT_WRITE = "project:write" PERM_PROJECT_DELETE = "project:delete" PERM_USER_ADMIN = "user:admin"These identifiers become a shared language across routes, services, tests, and documentation.
2) Represent the actor (current user) and their grants
Assume authentication already provides a current actor (e.g., via g.current_user or a request context object). Authorization should not depend on request parsing; it should depend on an actor object and domain objects.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
from dataclasses import dataclass @dataclass(frozen=True) class Actor: id: int roles: set[str] permissions: set[str] def actor_has(actor: Actor, perm: str) -> bool: return perm in actor.permissionsIf your system uses roles only, you can compute permissions from roles once per request (or at login) and keep checks uniform.
3) Add ownership checks as first-class helpers
Ownership is not a role; it is a relationship between the actor and a specific resource.
def is_owner(actor: Actor, resource_owner_id: int) -> bool: return actor.id == resource_owner_idKeep ownership checks simple and composable; do not embed database queries inside them unless you are intentionally building a policy that loads related data.
4) Encode business rules in policy functions
Policies are pure(ish) functions that answer “may actor do action on resource?”. They can combine permissions, ownership, and resource state.
from permissions import PERM_PROJECT_READ, PERM_PROJECT_WRITE def can_view_project(actor: Actor, project) -> bool: # Example rule: public projects are readable by anyone authenticated; # private projects require explicit read permission or ownership. if project.is_public: return True return actor_has(actor, PERM_PROJECT_READ) or is_owner(actor, project.owner_id) def can_edit_project(actor: Actor, project) -> bool: # Example rule: editing requires write permission AND project not archived, # or ownership with a special constraint. if project.is_archived: return False if actor_has(actor, PERM_PROJECT_WRITE): return True return is_owner(actor, project.owner_id) and project.allow_owner_editsPolicies should be easy to unit test: pass an actor and a resource, assert True/False.
Keeping permission logic out of route handlers
Approach A: Decorators for route-level permission gates
Use decorators for checks that do not require loading a specific resource (e.g., “must have admin permission” or “must have project:write to create”). This keeps routes clean and consistent.
from functools import wraps class Forbidden(Exception): pass def require_permission(perm: str): def decorator(fn): @wraps(fn) def wrapper(*args, **kwargs): actor = getattr(g, "current_actor", None) if actor is None or perm not in actor.permissions: raise Forbidden() return fn(*args, **kwargs) return wrapper return decoratorExample usage for a write operation that is not resource-specific:
@bp.post("/projects") @require_permission(PERM_PROJECT_WRITE) def create_project(): payload = request.get_json() project = project_service.create_project(actor=g.current_actor, data=payload) return jsonify(project.to_dict()), 201The route does not contain authorization logic; it declares a requirement.
Approach B: Service-level guards for resource-specific checks
For checks that depend on the resource (ownership, state, membership), place the guard in the service method that loads and mutates the resource. This prevents accidental bypass when the same service is called from multiple routes or background jobs.
class NotFound(Exception): pass class Forbidden(Exception): pass class ProjectService: def __init__(self, session): self.session = session def get_project_for_view(self, actor: Actor, project_id: int): project = self.session.get(Project, project_id) if project is None: raise NotFound() # Anti-leak pattern: if actor cannot view, respond as not found. if not can_view_project(actor, project): raise NotFound() return project def update_project(self, actor: Actor, project_id: int, data: dict): project = self.session.get(Project, project_id) if project is None: raise NotFound() # For write operations, you may choose Forbidden (see next section). if not can_edit_project(actor, project): raise Forbidden() project.name = data.get("name", project.name) self.session.add(project) return projectRoutes become orchestration only:
@bp.get("/projects/<int:project_id>") def get_project(project_id: int): project = project_service.get_project_for_view(g.current_actor, project_id) return jsonify(project.to_dict()) @bp.patch("/projects/<int:project_id>") def patch_project(project_id: int): payload = request.get_json() project = project_service.update_project(g.current_actor, project_id, payload) return jsonify(project.to_dict())This pattern centralizes authorization where the resource is loaded and modified.
Protecting read vs. write operations
Read operations: consider “not found” to avoid information leaks
For sensitive resources, returning 404 Not Found when the actor lacks access prevents confirming that the resource exists. This is especially useful for endpoints like GET /projects/<id> where IDs may be guessable.
Common rule of thumb:
- Read: if unauthorized, often return
404(hide existence) for private resources. - Write: if the actor can locate the resource but cannot modify it, return
403 Forbidden(clear denial), unless existence itself is sensitive.
Be consistent per resource type and document the behavior so clients can handle it.
Write operations: explicit forbidden vs. not found
For updates/deletes, you have two defensible patterns:
- Strict anti-leak: return
404for both “missing” and “no access”. This hides existence but can complicate client UX and auditing. - Explicit authorization: return
403when the resource exists but the actor lacks permission. This is clearer for clients and logs, but reveals existence.
Choose based on threat model. For multi-tenant B2B data, anti-leak is often preferred for reads; for internal/admin tools, explicit 403 is usually acceptable.
Step-by-step: implementing a maintainable policy-based authorization
Step 1: Create a policy module per domain
Group policies by domain object (e.g., project_policy.py, invoice_policy.py) so they are discoverable and testable.
# project_policy.py from permissions import PERM_PROJECT_READ, PERM_PROJECT_WRITE, PERM_PROJECT_DELETE def can_delete_project(actor: Actor, project) -> bool: # Example: only explicit delete permission; owners cannot delete if locked if project.is_locked: return False return PERM_PROJECT_DELETE in actor.permissionsStep 2: Add a small “authorization service” (optional) for consistency
If you want a single entry point, wrap policy calls in an authorizer that raises consistent exceptions.
class Authorizer: def require(self, allowed: bool, *, hide: bool = False): if allowed: return if hide: raise NotFound() raise Forbidden() def require_view_project(self, actor: Actor, project): self.require(can_view_project(actor, project), hide=True) def require_edit_project(self, actor: Actor, project): self.require(can_edit_project(actor, project), hide=False)Then in services:
def update_project(self, actor: Actor, project_id: int, data: dict): project = self.session.get(Project, project_id) if project is None: raise NotFound() self.authorizer.require_edit_project(actor, project) ...Step 3: Use decorators only for coarse-grained gates
Decorators work best for global permissions that do not depend on resource state. Keep them simple and avoid database access inside decorators to prevent hidden performance costs.
@bp.delete("/projects/<int:project_id>") @require_permission(PERM_PROJECT_DELETE) def delete_project(project_id: int): project_service.delete_project(g.current_actor, project_id) return "", 204Even here, the service should still enforce resource-state rules (e.g., locked projects cannot be deleted), because a decorator cannot know that.
Documenting required permissions (and keeping docs in sync)
Authorization becomes hard to maintain when requirements live only in developers’ heads. Treat permission requirements as an artifact that is easy to review during code changes.
Endpoint-permission matrix
| Endpoint | Method | Operation | Required permission(s) | Additional policy | Unauthorized response |
|---|---|---|---|---|---|
| /projects | GET | List projects | project:read (optional if listing only public) | Filter by visibility/membership | 200 with filtered results |
| /projects/<id> | GET | Read project | project:read OR owner | Public projects readable | 404 (hide existence) |
| /projects | POST | Create project | project:write | May enforce quotas | 403 |
| /projects/<id> | PATCH | Update project | project:write OR owner (if allowed) | Cannot edit archived | 403 (or 404 if strict anti-leak) |
| /projects/<id> | DELETE | Delete project | project:delete | Cannot delete locked | 403 (or 404 if strict anti-leak) |
Keep this matrix near the code (e.g., in a docs/authorization.md file or as a module docstring) and update it in the same pull request as endpoint changes.
Inline documentation via decorators and docstrings
Even without a full OpenAPI workflow, you can make permission requirements visible:
- Add a short docstring on the route:
"""Requires project:write""" - Name decorators clearly:
@require_permission(PERM_PROJECT_WRITE) - For resource policies, reference the policy function name in service code (e.g.,
require_edit_project)
Common pitfalls and how to avoid them
Scattering role checks across routes
If routes contain logic like if "admin" in actor.roles in multiple places, you will eventually create inconsistent behavior. Prefer permission identifiers and policy functions.
Checking permissions after performing the action
Authorization should happen before mutating state. In services, load resource, authorize, then update. This also reduces the chance of partial updates when an exception occurs.
Leaking existence through error differences
If GET /resource/123 returns 403 for unauthorized users and 404 for missing resources, an attacker can enumerate valid IDs. For sensitive resources, standardize on 404 when access is denied for reads.
Forgetting authorization on non-HTTP entry points
If you have CLI commands, background jobs, or internal service calls that reuse the same service methods, service-level guards ensure authorization rules remain enforced consistently (or are explicitly bypassed with a clearly named internal method).