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.

flowchart TD A[Request] --> B{Is the caller identified?} B -- no --> C[401 or redirect to login] B -- yes --> D{Does a role or permission allow the action?} D -- no --> E[403 Forbidden] D -- yes --> F{May the caller touch this object?} F -- no --> G[404 or 403] F -- yes --> H[Run the view]
Figure 1. The decisions a protected request goes through

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".

abort(401), or Flask-Login’s LoginManager.unauthorized(), which redirects browsers to the login view and answers 401 when no login_view is set.

302 redirect to login

The browser flavour of 401 for pages a human opens.

login_manager.login_view in Flask-Login; SECURITY_ redirects in Flask-Security.

403 Forbidden

The caller is known, but the server refuses the action. Logging in again does not help.

abort(403) from a role, permission or policy check.

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 db.one_or_404().

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 (@roles_required)

Explicit and visible next to the route; easy to read in a review.

A new view without the decorator is silently open.

before_request on a blueprint

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.

before_request on the application with a default-deny rule

Everything is protected unless explicitly marked public.

Needs an allow-list of public endpoints and careful handling of static and error pages.

Query filter (where(Book.owner_id == current_user.id))

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 ({% if can(...) %})

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

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)

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)

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))

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))

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 set owner_id, status or roles (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)

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)

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

SECURITY_TWO_FACTOR

Enables the two-factor login flow. Defaults to False.

SECURITY_TWO_FACTOR_ENABLED_METHODS

Which second factors are offered: email, authenticator and sms by default.

SECURITY_TWO_FACTOR_REQUIRED

Every user must enrol and use a second factor. Defaults to False.

SECURITY_TOTP_SECRETS and SECURITY_TOTP_ISSUER

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.

SECURITY_WEBAUTHN

Enables WebAuthn credentials (passkeys and security keys). Defaults to False.

SECURITY_WAN_ALLOW_AS_FIRST_FACTOR, SECURITY_WAN_ALLOW_AS_MULTI_FACTOR

Whether a passkey may replace the password, and may act as both factors in one step.

SECURITY_FRESHNESS

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-"))}))

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 localStorage exposes it to any cross-site scripting bug; an HttpOnly cookie with SameSite is safer for browsers, at the cost of needing CSRF protection.

  • Identities must be strings. Recent PyJWT versions require the sub claim to be a string, so create_access_token(identity=user.id) with an integer fails; pass str(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_request that 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/1 but forgetting DELETE /books/1 is 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 expose roles; 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.route outermost, then the authentication decorator, then the authorization decorator, then the view; a role check placed above login_required would 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/books for one user serves it to the next. Responses that depend on the user must vary on the credentials (Vary: Cookie is set automatically when the session is accessed) or be Cache-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

current_user.is_authenticated is a property (it was a method before); calling it, as older tutorials do, raises TypeError.

Flask 2.3

before_first_request is removed, so lazy one-time role seeding moves into the application factory or a flask command; setup methods called after the first request raise an error.

Werkzeug 3.0

generate_password_hash defaults to scrypt (it was PBKDF2-SHA256); Werkzeug 3.1 raised the PBKDF2 iteration count.

SQLAlchemy 2.0

Model.query.get(id) is legacy: use db.session.get(Model, id), db.get_or_404(Model, id), db.one_or_404(select(...)) and Mapped[...] annotations, as in this page.

Flask-JWT-Extended 4

@jwt_required() needs the parentheses, and recent PyJWT requires the identity (the sub claim) to be a string.

Flask-Security-Too 5.x

The original Flask-Security is unmaintained; the fork keeps the flask_security import name. The default password hash is Argon2, which needs argon2_cffi.

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_admin flag; 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_required without parentheses, passes a non-string identity to create_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(...) and Model.query.get_or_404(id) still runs but is legacy in SQLAlchemy 2.x; prefer db.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.