Authorization & Roles
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Flask 3.1.3, Werkzeug 3.1.9, Flask-Login 0.6.3, Flask-SQLAlchemy 3.1.1, SQLAlchemy 2.x,
Flask-Principal 0.4.0, Flask-Security-Too 5.9.1, Flask-JWT-Extended 4.7.4 and Python 3.13, checked on 2026-10-07; every
example was executed with those versions. Flask 3.2 is not released yet, and its unreleased changelog contains nothing
that changes how authorization is written.
Authentication answers the question who is this?; authorization answers what may this person do?. Flask has no
built-in answer to either: it gives you the request, the session, abort() and decorators, and everything else comes
from your own code or from an extension. This page builds the authorization half for the running Bookshelf
application: role checks with decorators, permissions and RBAC, attribute-based (ABAC) and policy-based (PBAC) rules,
ownership checks on individual objects, admin-only blueprints, the two extensions that package these ideas
(Flask-Principal and Flask-Security), roles carried in API tokens, checks in templates, and how to test all of it.
The authentication half (password hashing, login views, Flask-Login, remember-me cookies) is the topic of the
authentication page of this section; the examples here use a minimal Flask-Login setup so that they run on their own.
Authorization in One Picture
Every protected request passes up to three questions, in this order, and each failure has its own HTTP status.
The status codes are not interchangeable, and choosing them badly leaks information or breaks clients.
| Status | Meaning | Typical use in Flask |
|---|---|---|
401 Unauthorized |
The request has no valid credentials. The name is historical: it means "unauthenticated". |
|
302 redirect to login |
The browser flavour of 401 for pages a human opens. |
|
403 Forbidden |
The caller is known, but the server refuses the action. Logging in again does not help. |
|
404 Not Found |
Used on purpose when even the existence of an object must not be revealed to this caller. |
Queries that filter by owner and then call |
The error-handling page of this section shows how to turn these abort() calls into JSON or HTML error pages. The rest
of this page only decides when to abort.
Where a Rule Can Be Enforced
Flask offers several places to put an authorization rule. They are complementary: the broader and earlier the check, the harder it is to forget, and the closer to the data, the harder it is to bypass.
| Place | Strength | Weakness |
|---|---|---|
View decorator ( |
Explicit and visible next to the route; easy to read in a review. |
A new view without the decorator is silently open. |
|
One check covers every view of the blueprint, including those added later. |
Applies only to that blueprint’s own views, not to the rest of the application. |
|
Everything is protected unless explicitly marked public. |
Needs an allow-list of public endpoints and careful handling of |
Query filter ( |
The database never returns what the caller may not see, so there is no object to leak. |
Only covers the queries that remember to apply it; put it in one repository or helper function. |
Service or domain layer |
The rule is shared by HTTP views, CLI commands and background jobs. |
More structure; only worth it once the application has more than one entry point. |
Template ( |
Shows the right buttons. |
It is user interface, never security: the server must repeat every check. |
The hooks and view-decorators page of this section explains the mechanics of before_request, wraps and decorator
order; this page uses them.
The Bookshelf Roles Model
The examples share one small model. Users have many roles through an association table, books belong to an owner, and
a tiny Flask-Login setup loads the current user from the session. Passwords are hashed with Werkzeug, and the demo
password is generated at start-up, never written in the source. create_app() takes the functions that register the
routes of each example, so that every example can build a fresh application with its own routes (Flask refuses to add
routes after the first request).
import secrets
from functools import wraps
from flask import Blueprint, Flask, abort, jsonify, request
from flask_login import LoginManager, UserMixin, current_user, login_required, login_user
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import Column, ForeignKey, Table, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from werkzeug.security import check_password_hash, generate_password_hash
class Base(DeclarativeBase):
pass
db = SQLAlchemy(model_class=Base)
login_manager = LoginManager()
user_roles = Table(
"user_roles",
Base.metadata,
Column("user_id", ForeignKey("users.id"), primary_key=True),
Column("role_id", ForeignKey("roles.id"), primary_key=True),
)
class Role(db.Model):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(unique=True)
class User(UserMixin, db.Model):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(unique=True)
password_hash: Mapped[str]
roles: Mapped[list[Role]] = relationship(secondary=user_roles)
def has_role(self, *names: str) -> bool:
return set(names) <= {role.name for role in self.roles}
class Book(db.Model):
__tablename__ = "books"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str]
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
status: Mapped[str] = mapped_column(default="draft")
PASSWORD = secrets.token_urlsafe(12) # demo password of every seeded user, different on each run
PASSWORD_HASH = generate_password_hash(PASSWORD)
@login_manager.user_loader
def load_user(user_id: str):
return db.session.get(User, int(user_id))
@login_manager.unauthorized_handler
def unauthorized():
abort(401) # an API answers 401 instead of redirecting to a login page
def create_app(*register):
app = Flask(__name__)
app.config.update(SECRET_KEY=secrets.token_hex(), SQLALCHEMY_DATABASE_URI="sqlite://")
db.init_app(app)
login_manager.init_app(app)
@app.post("/login")
def login():
data = request.get_json()
user = db.session.scalar(select(User).where(User.name == data["name"]))
if user is None or not check_password_hash(user.password_hash, data["password"]):
abort(401)
login_user(user)
return jsonify(ok=True)
with app.app_context():
db.create_all()
roles = {name: Role(name=name) for name in ("reader", "editor", "admin")}
db.session.add_all(roles.values())
users = {
"alice": User(name="alice", password_hash=PASSWORD_HASH, roles=[roles["admin"]]),
"bob": User(name="bob", password_hash=PASSWORD_HASH, roles=[roles["editor"]]),
"carol": User(name="carol", password_hash=PASSWORD_HASH, roles=[roles["reader"]]),
}
db.session.add_all(users.values())
db.session.flush()
db.session.add_all([
Book(title="Dune", owner_id=users["bob"].id),
Book(title="Emma", owner_id=users["carol"].id),
])
db.session.commit()
for add_routes in register:
add_routes(app)
return app
def client_for(app, name=None):
client = app.test_client()
if name:
assert client.post("/login", json={"name": name, "password": PASSWORD}).status_code == 200
return client
Source: Flask-Login documentation
Three details matter for what follows. login_manager.unauthorized_handler replaces the default redirect, which suits
a JSON API; a server-rendered site would instead set login_manager.login_view. The Role rows are plain data, so an
administrator can create or reassign roles without a deployment. And has_role() reads the roles from the database on
the request that needs them, so a revoked role takes effect immediately, which is not true for roles stored in a cookie
or a token (see the API section).
Role Checks with Decorators
The simplest authorization is a decorator that tests the current user’s roles. Distinguish the two failures: no user is a 401, a user without the role is a 403. Returning 403 for the second case also avoids a redirect loop between a protected page and the login page.
def _guard(allowed):
def decorator(view):
@wraps(view)
def wrapped(*args, **kwargs):
if not current_user.is_authenticated:
return login_manager.unauthorized()
if not allowed(current_user):
abort(403)
return view(*args, **kwargs)
return wrapped
return decorator
def roles_required(*names):
"""The user must have all the roles."""
return _guard(lambda user: user.has_role(*names))
def roles_accepted(*names):
"""The user must have at least one of the roles."""
return _guard(lambda user: any(user.has_role(name) for name in names))
def role_routes(app):
@app.post("/books")
@roles_accepted("admin", "editor")
def create_book():
book = Book(title=request.get_json()["title"], owner_id=current_user.id)
db.session.add(book)
db.session.commit()
return jsonify(id=book.id), 201
@app.delete("/books/<int:book_id>")
@roles_required("admin")
def delete_book(book_id):
return jsonify(deleted=book_id)
app = create_app(role_routes)
for who in (None, "carol", "bob", "alice"):
client = client_for(app, who)
print(who, client.post("/books", json={"title": "Persuasion"}).status_code, client.delete("/books/1").status_code)
Source: Flask: View Decorators
The loop prints None 401 401, carol 403 403, bob 201 403 and alice 201 200. The route decorator must be
the outermost one: route() registers whatever function it receives, so a decorator placed above it is applied after
the registration and the registered view has no check. The same rule applies to Flask-Login’s own
login_required, which can be stacked below the route decorator and above the role decorator.
A decorator reads current_user, never a field, cookie or header the client chose. The signed session cookie is
trustworthy because the server signed it; a header such as X-Role: admin is not.
The is_admin Flag and Why Roles Replace It
Tutorials (and the cookbook-style admin chapters) often start with a boolean is_admin column. It works until the
second privileged group appears: editors, moderators, support staff, an auditor who may read everything but change
nothing. Each new group adds a column, and if user.is_admin or user.is_moderator spreads through the views. A roles
table with a roles_required("admin") decorator keeps the check in one place and makes new groups a data change. A
boolean is acceptable for a prototype with a single privileged account, and migrating it later is a three-step
Alembic revision: create the tables, copy is_admin to an admin role, drop the column.
From Roles to Permissions: RBAC
A role is a label for a set of people; a permission is a statement about an action. In role-based access control
(RBAC) users get roles, roles get permissions, and the code checks permissions. The views then say what they do
(book:delete) instead of who may do it (admin), so adding a "librarian" role is a change to a table, not to a
hundred decorators.
ROLE_PERMISSIONS = {
"reader": {"book:read"},
"editor": {"book:read", "book:create", "book:edit"},
"admin": {"book:read", "book:create", "book:edit", "book:delete", "user:manage"},
}
def permissions_of(user) -> frozenset[str]:
return frozenset().union(*(ROLE_PERMISSIONS.get(role.name, set()) for role in user.roles))
def permission_required(permission: str):
return _guard(lambda user: permission in permissions_of(user))
def permission_routes(app):
@app.get("/books")
@permission_required("book:read")
def list_books():
return jsonify(titles=[book.title for book in db.session.scalars(select(Book))])
@app.post("/books")
@permission_required("book:create")
def create_book():
return jsonify(created=True), 201
@app.delete("/books/<int:book_id>")
@permission_required("book:delete")
def delete_book(book_id):
return jsonify(deleted=book_id)
app = create_app(permission_routes)
carol = client_for(app, "carol")
print(carol.get("/books").get_json(), carol.post("/books", json={}).status_code)
Source: Flask: View Decorators
The dictionary above is the simplest storage. When administrators must edit the mapping, move it to a
role_permissions table (or a column on Role, as Flask-Security does) and cache permissions_of() per request in
flask.g. Keep permission names stable strings of the form resource:action, because they end up in logs, tokens and
audit trails.
ABAC: Rules over Attributes
RBAC cannot express "an editor may change a book only while it is a draft and only if it is theirs". That rule depends on attributes: of the subject (id, roles, department), of the resource (owner, status), and of the environment (time, network, a maintenance flag). Attribute-based access control (ABAC) is simply a function over those attributes, and in Python it is just a function:
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@dataclass(frozen=True)
class Subject:
user_id: int
roles: frozenset[str]
@dataclass(frozen=True)
class BookResource:
owner_id: int
status: str # "draft" or "published"
locked_until: datetime | None = None
def can_edit(subject: Subject, book: BookResource, now: datetime) -> bool:
if "admin" in subject.roles:
return True
if book.locked_until is not None and now < book.locked_until:
return False # environment attribute: an editorial lock
if book.status == "published":
return False # resource attribute: published books are frozen
return "editor" in subject.roles and book.owner_id == subject.user_id
now = datetime.now(timezone.utc)
bob = Subject(2, frozenset({"editor"}))
draft = BookResource(owner_id=2, status="draft")
print(can_edit(bob, draft, now))
print(can_edit(bob, BookResource(2, "published"), now))
print(can_edit(bob, BookResource(2, "draft", locked_until=now + timedelta(hours=1)), now))
print(can_edit(Subject(3, frozenset({"editor"})), draft, now))
Source: Flask: View Decorators
The four calls print True, False, False and False. Keep such functions pure (no request, no database access)
so that they can be unit-tested with plain objects, and call them from the view after loading the resource.
PBAC: Policies as Data
Policy-based access control (PBAC) goes one step further: the rules are values, evaluated by one engine, so they can be listed, audited, stored and changed without editing view code. The two properties every engine needs are default deny (no matching rule means no access) and deny overrides allow (one explicit prohibition wins over any number of grants).
from collections.abc import Callable
@dataclass(frozen=True)
class Policy:
name: str
effect: str # "allow" or "deny"
actions: frozenset[str]
applies: Callable[[Subject, BookResource, datetime], bool]
POLICIES = [
Policy("admins-manage-books", "allow", frozenset({"book:edit", "book:delete"}),
lambda s, b, n: "admin" in s.roles),
Policy("owners-edit-drafts", "allow", frozenset({"book:edit"}),
lambda s, b, n: b.owner_id == s.user_id and b.status == "draft"),
Policy("no-changes-while-locked", "deny", frozenset({"book:edit", "book:delete"}),
lambda s, b, n: b.locked_until is not None and n < b.locked_until and "admin" not in s.roles),
]
def decide(policies, action, subject, book, now) -> bool:
matching = [p for p in policies if action in p.actions and p.applies(subject, book, now)]
if any(p.effect == "deny" for p in matching):
return False # deny overrides allow
return any(p.effect == "allow" for p in matching) # default deny
locked = BookResource(2, "draft", locked_until=now + timedelta(hours=1))
print(decide(POLICIES, "book:edit", bob, draft, now))
print(decide(POLICIES, "book:edit", bob, locked, now))
print(decide(POLICIES, "book:delete", bob, draft, now))
print(decide(POLICIES, "book:delete", Subject(1, frozenset({"admin"})), locked, now))
Source: Flask: View Decorators
The outputs are True, False, False and True. Standardised engines exist for the same idea (Open Policy Agent
with Rego, Cedar, Casbin and the XACML family); in a Flask application they sit behind the same single function:
decide(...) becomes an HTTP call or a library call, and the views do not change. Start with RBAC, add ABAC rules as
plain functions when a rule needs an attribute, and reach for a policy engine only when rules must change without a
deployment or must be audited by non-developers.
Object-Level Authorization: Ownership
Role checks protect kinds of action; most real breaches come from the next level down, the individual object.
GET /books/42 is allowed for every reader, so a reader who changes 42 to 43 reads somebody else’s data. This is
"broken object level authorization" (IDOR) and is first on the OWASP API Security list. The reliable fix is to put the
ownership condition in the query, so that a book the caller may not see simply does not exist for them.
def visible_books(statement):
"""Restrict a select(Book) to what the current user may see: everything for admins, own books otherwise."""
if current_user.has_role("admin"):
return statement
return statement.where(Book.owner_id == current_user.id)
def ownership_routes(app):
@app.get("/books/<int:book_id>")
@login_required
def get_book(book_id):
book = db.one_or_404(visible_books(select(Book).where(Book.id == book_id)))
return jsonify(id=book.id, title=book.title)
@app.patch("/books/<int:book_id>")
@login_required
def update_book(book_id):
book = db.one_or_404(visible_books(select(Book).where(Book.id == book_id)))
data = request.get_json()
if "title" in data: # explicit allow-list: owner_id and status are not client fields
book.title = data["title"]
db.session.commit()
return jsonify(id=book.id, title=book.title, owner_id=book.owner_id)
@app.get("/my/books")
@login_required
def my_books():
statement = visible_books(select(Book).order_by(Book.id))
return jsonify(ids=[book.id for book in db.session.scalars(statement)])
app = create_app(ownership_routes)
bob = client_for(app, "bob")
print(bob.get("/books/1").status_code, bob.get("/books/2").status_code)
print(bob.patch("/books/1", json={"title": "Dune Messiah", "owner_id": 3}).get_json())
print(client_for(app, "alice").get("/my/books").get_json())
Bob reads his own book (200) and gets 404 for Carol’s (404), the update ignores the forged owner_id and keeps
the owner, and the admin Alice lists both books. Three design choices are visible here.
-
404 or 403? Answering 403 for "exists but not yours" confirms that the id exists, which helps an attacker enumerate. Answering 404 hides it. Use 403 when the caller already knows the object (a shared document they were removed from), 404 when existence itself is private.
-
One helper.
visible_books()is the single place where "who sees what" is written, and every query that returns books goes through it. A reviewer checks one function, not every view. -
Allow-lists for writes. Copying the whole JSON body into a model (
Book(**data)) lets a client setowner_id,statusorroles(mass assignment). Name the fields that may be written, or validate with a schema that rejects unknown keys.
For update and delete, combine both layers: the permission decorator decides whether the action is allowed at all,
and the owner-filtered query decides whether it is allowed on this object. The hooks page of this section covers
loading the object once in before_request or a URL processor so that decorators and views share it.
Admin-Only Blueprints
An administration area is a good fit for a blueprint with a before_request hook: one function protects every view
the blueprint has now and every view added later. The hook of a blueprint runs only for requests routed to that
blueprint, so it does not slow down or accidentally block the rest of the application (before_app_request is the
variant that applies to every request).
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
@admin_bp.before_request
def admin_only():
if not current_user.is_authenticated:
return login_manager.unauthorized()
if not current_user.has_role("admin"):
abort(403)
@admin_bp.get("/users")
def list_users():
users = db.session.scalars(select(User).order_by(User.id))
return jsonify([{"name": user.name, "roles": sorted(role.name for role in user.roles)} for user in users])
@admin_bp.post("/users/<int:user_id>/roles")
def grant_role(user_id):
name = request.get_json().get("role")
if name not in ROLE_PERMISSIONS: # only known roles can be granted
abort(422)
user = db.get_or_404(User, user_id)
role = db.session.scalar(select(Role).where(Role.name == name))
if role not in user.roles:
user.roles.append(role)
db.session.commit()
return jsonify(name=user.name, roles=sorted(role.name for role in user.roles))
def admin_routes(app):
app.register_blueprint(admin_bp)
@app.get("/")
def home():
return jsonify(public=True)
app = create_app(admin_routes, permission_routes)
anonymous, bob, alice = client_for(app), client_for(app, "bob"), client_for(app, "alice")
print(anonymous.get("/").status_code, anonymous.get("/admin/users").status_code, bob.get("/admin/users").status_code)
print(alice.post("/admin/users/2/roles", json={"role": "admin"}).get_json())
print(bob.get("/admin/users").status_code, bob.delete("/books/1").status_code)
The first line prints 200 401 403. After Alice grants Bob the admin role, Bob reaches /admin/users (200)
and DELETE /books/1 (200) on the very next request, because has_role() reads the database each time.
Two cautions apply. First, the role grant itself is the most sensitive endpoint of the application: keep the allowed
roles in a list (422 for anything else), forbid the last administrator from removing their own role, and log who
granted what. Second, before_request on a blueprint does not cover routes registered on the application or on a
different blueprint; a protected area split across two blueprints needs the hook on both, or on a parent blueprint
that nests them.
The admin-interfaces page of this section shows the same rule applied to a generated Flask-Admin interface, where
is_accessible() and inaccessible_callback() play the role of the before_request hook.
Flask-Principal: Needs and Permissions
Flask-Principal models authorization with three ideas. An identity is who is acting, and it carries a set of
provides, the needs that identity satisfies (UserNeed(5), RoleNeed("admin"), ItemNeed("edit", 42, "book")).
A permission is a set of needs, any one of which is enough. Permission.require() returns a context that is a
decorator and a context manager, and raises PermissionDenied or aborts with an HTTP status when the identity does
not provide a required need. The extension does not authenticate anybody, so it is combined with Flask-Login, whose
current_user the identity loader reads.
from flask_principal import (Identity, ItemNeed, Permission, Principal, RoleNeed, UserNeed, identity_loaded)
def principal_routes(app):
principal = Principal(app, use_sessions=False) # identity comes from Flask-Login, not from a session key
admin_permission = Permission(RoleNeed("admin"))
@principal.identity_loader
def identity_from_flask_login():
return Identity(current_user.id) if current_user.is_authenticated else None
@identity_loaded.connect_via(app)
def add_needs(sender, identity):
identity.provides.add(UserNeed(identity.id))
for role in current_user.roles:
identity.provides.add(RoleNeed(role.name))
for book_id in db.session.scalars(select(Book.id).where(Book.owner_id == identity.id)):
identity.provides.add(ItemNeed("edit", book_id, "book"))
@app.delete("/books/<int:book_id>")
@admin_permission.require(http_exception=403)
def delete_book(book_id):
return jsonify(deleted=book_id)
@app.patch("/books/<int:book_id>")
def edit_book(book_id):
Permission(ItemNeed("edit", book_id, "book"), RoleNeed("admin")).test(http_exception=403)
return jsonify(edited=book_id)
app = create_app(principal_routes)
bob, alice, anonymous = client_for(app, "bob"), client_for(app, "alice"), client_for(app)
print(bob.patch("/books/1").status_code, bob.patch("/books/2").status_code, alice.patch("/books/2").status_code)
print(bob.delete("/books/1").status_code, alice.delete("/books/1").status_code, anonymous.delete("/books/1").status_code)
Source: Flask-Principal documentation
Bob may edit his own book through its ItemNeed but not Carol’s, and the admin may edit both: the first line prints
200 403 200, and the second 403 200 403. Note the last value: Principal knows only "identity lacks the needs", so an
anonymous caller also gets 403, not 401. Use Permission.can() for a boolean when a view must branch instead of abort,
and http_exception=None plus an errorhandler(PermissionDenied) when you prefer to render the refusal yourself.
Flask-Principal fits applications whose rules are "this identity satisfies one of these needs", and it separates the
loading of needs (one signal handler) from their use (decorators). Its weaknesses are age and scope: the last release
(version 0.4.0) is from June 2013 and is marked Beta, with its default session storage the identity stays
fixed until the identity_changed signal announces a new one (the loader used above avoids this by rebuilding the
needs on every request), and it offers nothing for passwords, tokens or recovery. It still works with
Flask 3.1, and Flask-Security depends on it, but for a new project the choice is usually between the explicit
decorators of this page and Flask-Security.
Flask-Security: The Batteries-Included Option
Flask-Security-Too (the maintained fork of the abandoned Flask-Security; the distribution is flask-security-too, the
import name is flask_security) bundles what the previous sections build by hand and a good deal more: registration,
login and logout views, password hashing, password reset and email confirmation, roles and permissions on the user
model, the decorators roles_required, roles_accepted, permissions_required, permissions_accepted and
auth_required, token authentication, two-factor authentication and WebAuthn passkeys. It is built on Flask-Login and
Flask-Principal, and stores users in Flask-SQLAlchemy, MongoEngine or other back ends through a user datastore.
The ready-made model mixins (flask_security.models.fsqla_v3) provide the columns, including a permissions list on
each role. The decorators then work on roles and on those permissions:
import flask_security as fsec
from flask_security.models import fsqla_v3 as fsqla
def create_security_app(**extra_config):
app = Flask(__name__)
app.config.update(
SECRET_KEY=secrets.token_hex(),
SECURITY_PASSWORD_SALT=secrets.token_hex(),
SQLALCHEMY_DATABASE_URI="sqlite://",
WTF_CSRF_ENABLED=False, # only for the test client; keep CSRF on in real applications
**extra_config,
)
fs_db = SQLAlchemy()
fsqla.FsModels.set_db_info(fs_db)
class Role(fs_db.Model, fsqla.FsRoleMixin): # defined here only so this page's other examples keep
pass # their own User and Role; at module level in a real project
class User(fs_db.Model, fsqla.FsUserMixin):
pass
class WebAuthn(fs_db.Model, fsqla.FsWebAuthnMixin): # the v3 mixins expect this model to exist
pass
fs_db.init_app(app)
store = fsec.SQLAlchemyUserDatastore(fs_db, User, Role)
fsec.Security(app, store)
@app.get("/shelf")
@fsec.auth_required()
def shelf():
return jsonify(user=fsec.current_user.email)
@app.delete("/books/<int:book_id>")
@fsec.roles_required("admin")
def delete_book(book_id):
return jsonify(deleted=book_id)
@app.post("/books")
@fsec.permissions_required("books-write")
def create_book():
return jsonify(created=True), 201
with app.app_context():
fs_db.create_all()
store.find_or_create_role(name="admin", permissions={"books-write", "books-delete"})
store.find_or_create_role(name="editor", permissions={"books-write"})
store.create_user(email="alice@example.com", password=fsec.hash_password(PASSWORD), roles=["admin"])
store.create_user(email="bob@example.com", password=fsec.hash_password(PASSWORD), roles=["editor"])
fs_db.session.commit()
return app
sec_app = create_security_app()
def sec_client(email):
client = sec_app.test_client()
assert client.post("/login", json={"email": email, "password": PASSWORD}).status_code == 200
return client
alice, bob = sec_client("alice@example.com"), sec_client("bob@example.com")
anonymous = sec_app.test_client()
print(anonymous.get("/shelf").status_code, anonymous.get("/shelf", headers={"Accept": "application/json"}).status_code)
print(bob.delete("/books/1").status_code, alice.delete("/books/1").status_code, bob.post("/books").status_code)
Source: Flask-Security-Too: Quick Start
Anonymous browsers are redirected to the login page (302), but a client that asks for JSON receives 401. Bob lacks
the admin role (403), Alice has it (200), and Bob’s editor role carries the books-write permission, so he can
create books (201). Flask-Security’s own documentation explains the rules for combining decorators: an endpoint with
only an authorization decorator never answers 401 and never redirects to login, it always answers 403, so add
auth_required() below the route when unauthenticated callers should be sent to the login page or get a 401. Do not
confuse its permissions_required("..."), a string stored on the role, with Flask-Principal’s Permission class; both
exist in the same process because Flask-Security uses Flask-Principal internally.
Three further details are worth knowing. The default SECURITY_PASSWORD_HASH is argon2, which needs the argon2_cffi
package (pip install "flask-security-too[common]"). The refusal is customisable: register a function with
security.unauthz_handler to raise your own exception or render JSON, or set SECURITY_UNAUTHORIZED_VIEW to a view
that flashes a message and redirects. And the secrets (SECRET_KEY, SECURITY_PASSWORD_SALT) must come from the
environment or the configuration system, never from the source; the quick start’s own comments say to generate them
with the secrets module.
Two-Factor Authentication and WebAuthn
Flask-Security adds a second factor and passkeys through configuration. Roles can then decide who must use them: since
Flask-Security-Too 5.8.0 the user model has a check_tf_required() method whose default follows the global setting, and an application
can override it on its User class so that administrators are forced to enrol while readers are not. The relevant settings are:
| Setting | Effect |
|---|---|
|
Enables the two-factor login flow. Defaults to |
|
Which second factors are offered: |
|
Every user must enrol and use a second factor. Defaults to |
|
Both are mandatory for two-factor: the key(s) that encrypt each user’s TOTP secret in the database and the name shown in authenticator apps. |
|
Enables WebAuthn credentials (passkeys and security keys). Defaults to |
|
Whether a passkey may replace the password, and may act as both factors in one step. |
|
How recent a login must be for the endpoints protected as sensitive (changing the password or the second factor). |
The following configuration enables both features. The extra packages come from pip install "flask-security-too[mfa]"
(cryptography, qrcode, phonenumberslite and webauthn); the TOTP key is generated, not typed.
from passlib.totp import generate_secret
mfa_app = create_security_app(
SECURITY_TWO_FACTOR=True,
SECURITY_TWO_FACTOR_ENABLED_METHODS=["authenticator"],
SECURITY_TOTP_SECRETS={"1": generate_secret()}, # in production: read from the environment or a vault
SECURITY_TOTP_ISSUER="Bookshelf",
SECURITY_WEBAUTHN=True,
)
print(sorted({rule.rule.split("/<")[0] for rule in mfa_app.url_map.iter_rules() if rule.rule.startswith(("/tf-", "/wan-"))}))
Source: Flask-Security-Too: Features
The printed routes (/tf-setup, /tf-validate, /wan-register and so on) are the views the extension added. Passkeys
and TOTP protect the login; they are authentication, and the authentication page of this section is the place for the
protocol side. What belongs to this page is the policy: who is required to use them, which actions need a fresh login,
and which roles may enrol others.
Roles and Scopes in Token-Based APIs
An API called by a mobile app or another service has no session; it receives a signed token (see the OAuth section for
the theory: scopes, claims and permissions and
JWT and JOSE). The token can carry the authorization data as claims, typically a
scope string or a roles list, so the API can decide without a database lookup. Flask-JWT-Extended 4.x supports
this with additional_claims when the token is created and get_jwt() when it is read.
from datetime import timedelta
from flask_jwt_extended import JWTManager, create_access_token, get_jwt, verify_jwt_in_request
def scope_required(scope: str):
def decorator(view):
@wraps(view)
def wrapped(*args, **kwargs):
verify_jwt_in_request() # 401 for a missing, expired or forged token
if scope not in get_jwt().get("scope", "").split():
abort(403)
return view(*args, **kwargs)
return wrapped
return decorator
def jwt_routes(app):
app.config["JWT_SECRET_KEY"] = secrets.token_hex() # in production: from the environment
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(minutes=10)
JWTManager(app)
@app.get("/api/books")
@scope_required("book:read")
def api_books():
return jsonify(roles=get_jwt()["roles"])
@app.delete("/api/books/<int:book_id>")
@scope_required("book:delete")
def api_delete(book_id):
return jsonify(deleted=book_id)
app = create_app(jwt_routes)
with app.app_context():
reader_token = create_access_token(identity="3", additional_claims={"roles": ["reader"], "scope": "book:read"})
client = app.test_client()
headers = {"Authorization": f"Bearer {reader_token}"}
print(client.get("/api/books").status_code, client.get("/api/books", headers=headers).get_json())
print(client.delete("/api/books/1", headers=headers).status_code)
The call without a token is 401 (the extension answers for a missing or invalid token), the reader’s token reads the
list ({'roles': ['reader']}), and DELETE is 403 because the book:delete scope is absent. Four facts about
tokens decide how much authorization to put in them.
-
Claims are stale by design. A token issued before an administrator revoked a role keeps that role until it expires. Keep access tokens short (here ten minutes), re-read the roles from the database for the most sensitive actions, or add a revocation check (
token_in_blocklist_loader). -
The client never holds the signing key. A token is signed with a secret (HS256) or a private key (RS256) that only the server knows; the client only carries the token. A new token is issued at login or refresh, not on every request.
-
Tokens are bearer credentials. Whoever has one can use it. Storing it in
localStorageexposes it to any cross-site scripting bug; anHttpOnlycookie withSameSiteis safer for browsers, at the cost of needing CSRF protection. -
Identities must be strings. Recent PyJWT versions require the
subclaim to be a string, socreate_access_token(identity=user.id)with an integer fails; passstr(user.id).
Scopes describe what a client application may do on behalf of a user, roles describe what a user is; an API that is
called by both browsers and partner services usually checks the scope and the user’s permission, and in addition the
ownership of the object, exactly as in the previous sections. Validating iss, aud and the algorithm is the job of the
authentication layer; the JWT page of this section covers it.
Authorization in Templates
Showing a button only to the people who can use it is good user experience. A context processor makes the same permission function available to every template, so that views and templates cannot disagree about the rules.
from flask import render_template_string
def template_routes(app):
@app.context_processor
def inject_can():
return {"can": lambda permission: current_user.is_authenticated and permission in permissions_of(current_user)}
@app.get("/shelf")
@login_required
def shelf():
return render_template_string(
"<p>{{ current_user.name }}</p>{% if can('book:delete') %}<button>Delete</button>{% endif %}"
)
app = create_app(permission_routes, template_routes)
for who in ("alice", "bob"):
client = client_for(app, who)
print(who, "<button>" in client.get("/shelf").get_data(as_text=True), client.delete("/books/1").status_code)
Alice sees the button and can delete (alice True 200), Bob sees nothing and, even if he sends the request by hand,
is refused (bob False 403). Templates are user interface; the second print is the security. Flask-Login makes
current_user available in every template without a context processor, and Flask-Security offers the same through
current_user and its template helpers.
Testing Authorization
Authorization bugs are regressions waiting to happen: a new route without a decorator is open, and nobody notices because the happy path still works. A table of who may do what is the cheapest protection. It lists every role against every protected endpoint, with the expected status, and runs on the test client:
app = create_app(permission_routes, ownership_routes, admin_routes, template_routes)
MATRIX = [
# who, method, url, expected status
(None, "GET", "/books", 401),
("carol", "GET", "/books", 200),
("carol", "POST", "/books", 403),
("bob", "POST", "/books", 201),
("bob", "GET", "/books/1", 200),
("bob", "GET", "/books/2", 404),
("alice", "GET", "/books/2", 200),
("bob", "GET", "/admin/users", 403),
("alice", "GET", "/admin/users", 200),
("bob", "DELETE", "/books/1", 403),
("alice", "DELETE", "/books/1", 200),
]
for who, method, url, expected in MATRIX:
client = client_for(app, who)
got = client.open(url, method=method, json={} if method == "POST" else None).status_code
assert got == expected, (who, method, url, got, expected)
print(f"{len(MATRIX)} authorization cases passed")
public = {"static", "login", "home"}
def is_protected(endpoint: str) -> bool:
blueprint = endpoint.rpartition(".")[0]
has_decorator = hasattr(app.view_functions[endpoint], "__wrapped__")
has_blueprint_hook = bool(blueprint) and bool(app.before_request_funcs.get(blueprint))
return has_decorator or has_blueprint_hook
unprotected = [rule.endpoint for rule in app.url_map.iter_rules()
if rule.endpoint not in public and not is_protected(rule.endpoint)]
print("unprotected endpoints:", unprotected)
The second half is a guard against forgetting. It walks app.url_map and lists the endpoints that have neither a view
decorator built with functools.wraps (which sets __wrapped__) nor a before_request hook on their blueprint; the
list is empty here. A test can assert that, apart from a short, reviewed allow-list of public endpoints, it always is. Under pytest the matrix becomes
@pytest.mark.parametrize, and the login helper becomes a fixture that logs in once per role. Also test the negative
paths that are easy to forget: the revoked role, the deactivated user, the expired token and the object that belongs to
another user.
Pitfalls and Security
The OWASP Top 10 puts Broken Access Control first, and the API Security list puts Broken Object Level Authorization first; both are, almost always, a missing check rather than a broken one.
-
Default deny. A view with no decorator should be closed, not open. Either put a global
before_requestthat requires authentication unless the endpoint is on an allow-list, or use the matrix test above to catch the omission. -
Hiding is not protecting. Removing a menu entry, a template button or an unlinked URL changes nothing for someone who types the URL or calls the API. Enforce on the server, on every verb: protecting
GET /books/1but forgettingDELETE /books/1is a classic. -
Never trust a client-supplied role, id or ownership field. The user id comes from
current_user, never from the JSON body, a hidden form field or a query parameter. Flask-WTF forms for users must not exposeroles; the admin endpoint that edits them is a separate, restricted route. -
Check the object, not only the action. Apply the ownership filter in the query, as in the ownership section, for every read, update and delete, including list endpoints, exports and file downloads.
-
Mind the order of decorators.
@app.routeoutermost, then the authentication decorator, then the authorization decorator, then the view; a role check placed abovelogin_requiredwould see an anonymous user. -
CSRF still applies. Cookie-authenticated, state-changing endpoints need CSRF protection whatever roles guard them; the CSRF section of the security page and the forms page cover Flask-WTF’s
CSRFProtect. -
Avoid caching authorized content. A shared cache that stores
GET /my/booksfor one user serves it to the next. Responses that depend on the user must vary on the credentials (Vary: Cookieis set automatically when the session is accessed) or beCache-Control: private. -
Make privilege changes rare, logged and revocable. Log who granted or removed roles, require a fresh login for those actions, and decide how long an existing session or token keeps the old privilege.
-
Do not leak through errors. A 403 body that says "requires role `superadmin`" teaches an attacker the role names. Keep detailed reasons in the logs.
-
The debugger is authorization bypass. The Werkzeug debugger allows arbitrary code execution to whoever reaches it and must never run in production; the security page of this section covers it.
The OWASP cheat sheets give the checklist form of these points: Authorization Cheat Sheet, IDOR Prevention Cheat Sheet and Access Control Cheat Sheet.
What Changed and Where Older Material Is Outdated
| Item | Change |
|---|---|
Flask-Login 0.3 |
|
Flask 2.3 |
|
Werkzeug 3.0 |
|
SQLAlchemy 2.0 |
|
Flask-JWT-Extended 4 |
|
Flask-Security-Too 5.x |
The original Flask-Security is unmaintained; the fork keeps the |
Flask 3.2 (unreleased) |
No change affecting authorization. The announced context merge and the 303 redirect default do not touch the APIs used here. |
Source: Flask: Changes
Where the Books Are Outdated
The books cover authorization lightly, and some of what they show should not be copied.
-
The cookbook’s admin chapter relies on a boolean
is_adminflag; the roles section shows why a roles table scales better, and Flask-Admin’s access hooks are in the admin-interfaces page. -
Full-Stack Flask and React (chapter 12) lists RBAC, ABAC and PBAC but stops at naming them, hashes passwords with Flask-Bcrypt (Werkzeug’s own scrypt default, or Argon2, is enough and needs no extension), sets a literal placeholder string as the application secret (a hard-coded key makes every session forgeable; generate it with
secrets.token_hex()), writes@jwt_requiredwithout parentheses, passes a non-string identity tocreate_access_token, and describes the signing key and token issuing inaccurately (see the tokens section for the correct facts). It also calls the cookie session "encrypted"; it is signed, not encrypted. -
Microservice APIs (chapter 11) is framework-agnostic and right about the principles: validate the token, then authorize on the resource, because a valid token alone does not mean the caller owns the object; the ownership section is that rule in Flask. Its code uses SQLAlchemy 1.x idioms that the examples here replace.
-
Code that builds queries with
Model.query.filter_by(...)andModel.query.get_or_404(id)still runs but is legacy in SQLAlchemy 2.x; preferdb.one_or_404(select(...)).
Summary
-
Authentication is "who"; authorization is "what". Answer 401 for no valid identity, 403 for a refused action, and 404 when the object’s existence is private.
-
Start with roles stored in the database and one decorator factory; check permissions in views and roles only to assign permissions (RBAC).
-
Use ABAC functions when a rule depends on attributes of the user, the object or the environment, and policy objects (PBAC) when rules must be data. Default deny, and deny overrides allow.
-
Enforce ownership in the query, with one helper, for reads and writes; whitelist writable fields.
-
Protect admin areas with a blueprint
before_request, and treat role-granting endpoints as the most sensitive code. -
Flask-Principal offers needs and permissions but is unmaintained since 2013; Flask-Security-Too is the batteries-included choice (roles, permissions, 2FA, WebAuthn) when you want an extension.
-
Tokens carry roles and scopes as claims that go stale; keep them short-lived and re-check what is sensitive.
-
Template checks are user interface; test the permission matrix and keep it in continuous integration.