Admin Interfaces with Flask-Admin

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-Admin 2.2.1, Flask-SQLAlchemy 3.1.1, SQLAlchemy 2.x, Flask-Login 0.6.3, Flask-Babel 4.0.0, Flask-WTF 1.3.0, WTForms 3.2.2, nh3 0.3.7 and Python 3.13, checked on 2026-10-07; every Python example was executed with those versions. Flask 3.2 is not released yet, and its unreleased changelog contains nothing that changes how an admin interface is built.

Almost every data-driven application ends up needing a back office: a place where staff list, search, edit and delete the records that the public site only reads. For the running Bookshelf application that means correcting a book’s price, publishing a draft, merging two authors, reading a rich-text description and uploading a cover. Flask ships no admin, so there are two options: write the pages yourself, or generate them from the models with Flask-Admin, the extension this page is about. Flask-Admin is a Pallets Ecosystem project (it lives in the pallets-eco GitHub organisation, like Flask-Login and Flask-WTF) and is the de facto admin framework for Flask. Django users should note the difference up front: the Django admin is built into the framework and secured by Django’s own permissions, while Flask-Admin is an add-on that is open to everyone until you secure it; the security section of this page is therefore not optional reading.

How Flask-Admin Works

Flask-Admin is built from four things that a Flask developer already knows.

  • Blueprints. Every admin view is a BaseView; when the view is registered, Flask-Admin creates a Flask blueprint for it and registers that blueprint under the admin’s URL prefix (/admin by default). A ModelView is a BaseView whose methods (index_view, create_view, edit_view, delete_view, details_view, action_view, export, ajax_lookup) are exposed as routes.

  • Jinja templates. The pages are Jinja templates shipped inside the package, extending admin/master.html, which in turn extends the theme’s base template.

  • WTForms. The create and edit forms are generated from the model’s columns by the same WTForms machinery that Flask-WTF uses (see the forms page of this section), and the delete and batch-action forms are WTForms forms too.

  • A model backend. flask_admin.contrib.sqla.ModelView reads and writes SQLAlchemy models through the session of Flask-SQLAlchemy (or Flask-SQLAlchemy-Lite). Other backends ship in the same package (MongoEngine, PyMongo, Peewee, a file manager, a Redis console); the ModelView attributes are common to all of them.

The request path of a list page shows where each customisation hook sits.

flowchart TD R[GET /admin/book/] --> A{is_accessible} A -- no --> B[inaccessible_callback: redirect or 403] A -- yes --> C[index_view] C --> D[get_list: get_query, search, filters, sort, paging] D --> E[column_formatters and the list template] E --> F[HTML page in the active theme]
Figure 1. The path of a request through a Flask-Admin ModelView

Saving a record follows a second path, and the hooks on_model_change and after_model_change are where business rules belong.

flowchart TD P[POST create or edit form] --> V{form validates, CSRF token ok} V -- no --> W[form redisplayed with errors] V -- yes --> M[form.populate_obj] M --> H[on_model_change] H --> S[session.commit] S -- error --> X[rollback and flash message] S -- ok --> Z[after_model_change then redirect]
Figure 2. The path of a create or edit form

Flask-Admin has no opinion about authentication: is_accessible() returns True by default, so a freshly registered admin is readable and writable by anybody who guesses its URL. The security section shows how to close that.

A Hand-Written Admin Versus Flask-Admin

The Flask Framework Cookbook (chapter 8) first builds the admin by hand, with ordinary views, templates and a boolean is_admin flag, and only then switches to Flask-Admin. The comparison is worth making once, because it shows what the extension saves and what it does not. A hand-written admin is a blueprint, a guard, a template and one view per operation.

import re
import secrets
from flask import Blueprint, Flask, abort, redirect, render_template_string, session, url_for
from flask_sqlalchemy import SQLAlchemy
from flask_wtf.csrf import CSRFProtect
from sqlalchemy import String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class HandBase(DeclarativeBase):
    pass


hand_db = SQLAlchemy(model_class=HandBase)


class Genre(hand_db.Model):
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(60))


handmade = Blueprint("handmade", __name__, url_prefix="/handmade")

LIST = """
<table>{% for genre in genres %}
  <tr><td>{{ genre.name }}</td><td>
    <form method="post" action="{{ url_for('handmade.delete', genre_id=genre.id) }}">
      <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button>Delete</button>
    </form></td></tr>{% endfor %}
</table>"""


@handmade.before_request
def only_staff():
    if not session.get("staff"):     # replace with a real role check; the point is: deny by default
        abort(403)


@handmade.get("/genres")
def genres():
    rows = hand_db.session.scalars(select(Genre).order_by(Genre.name))
    return render_template_string(LIST, genres=rows)


@handmade.post("/genres/<int:genre_id>/delete")
def delete(genre_id):
    hand_db.session.delete(hand_db.get_or_404(Genre, genre_id))
    hand_db.session.commit()
    return redirect(url_for("handmade.genres"))


hand_app = Flask(__name__)
hand_app.config.update(SECRET_KEY=secrets.token_hex(32), SQLALCHEMY_DATABASE_URI="sqlite://")
hand_db.init_app(hand_app)
CSRFProtect(hand_app)
hand_app.register_blueprint(handmade)
with hand_app.app_context():
    hand_db.create_all()
    hand_db.session.add(Genre(name="Fantasy"))
    hand_db.session.commit()

hand_client = hand_app.test_client()
print(hand_client.get("/handmade/genres").status_code)        # 403: the guard denies by default
with hand_client.session_transaction() as session_data:
    session_data["staff"] = True                              # pretend the guard let us in
page = hand_client.get("/handmade/genres").get_data(as_text=True)
token = re.search(r'name="csrf_token" value="([^"]+)"', page).group(1)
print(hand_client.post("/handmade/genres/1/delete").status_code)                      # 400: no token
print(hand_client.post("/handmade/genres/1/delete", data={"csrf_token": token}).status_code)  # 302

The output is 403, 400 and 302. That is one model, one operation and about forty lines; a real admin needs a list with paging, sorting and search, a create and an edit form generated from the columns, validation, foreign-key pickers, flash messages and a menu, for every model. Each of those is code you write, test and keep secure.

Concern Hand-written admin Flask-Admin

Time to a working CRUD for one model

Hours: list, form, templates, routes.

One line: admin.add_view(ModelView(Genre, db)).

List features (search, sort, filters, paging, export)

Written and tested by you.

Class attributes: column_searchable_list, column_filters, page_size, can_export.

Forms

Written with Flask-WTF or by hand, kept in sync with the model by you.

Generated from the mapped columns and relationships; tuned with form_* attributes.

Access control

Whatever you write: you start from nothing, which is safe by default.

Open by default: you must override is_accessible() on every view.

Look and feel

Your own templates and CSS, any design.

Bootstrap 4 theme with a few swatches; deeper changes need template overrides.

Unusual workflows (wizards, dashboards, approvals)

Natural: they are normal Flask views.

Possible through BaseView pages and hooks, but the generated pages are the centre of gravity.

Maintenance

Yours.

An extension that follows Flask, SQLAlchemy and WTForms releases.

The pragmatic rule: use Flask-Admin for staff-facing CRUD on your own tables and write ordinary views for anything that is part of the product. The cookbook’s boolean is_admin check is covered, and replaced by a roles table, on the authorization page; this page plugs the roles into Flask-Admin’s access hooks.

Installing and Registering a First Admin

Flask-Admin has a small core and optional extras for each backend. The sqlalchemy extra pulls in Flask-SQLAlchemy; the images extra adds Pillow for image upload fields; export adds tablib for export formats other than CSV; translation adds Flask-Babel. The package requires Python 3.10 or later.

pip install "flask-admin[sqlalchemy,translation]" flask-login flask-babel nh3

The examples of this page share one set of models and one helper that builds an application. The make_app() helper receives a function that registers views, so each section can show only the views it is about. Three points in it deserve attention.

  • Admin(...) takes theme=Bootstrap4Theme(...). The 1.x argument template_mode='bootstrap3' no longer exists.

  • The view is constructed as ModelView(Book, db), passing the Flask-SQLAlchemy object. Passing db.session, as most tutorials still do, is deprecated since 2.1 and emits a DeprecationWarning.

  • When Flask-Babel is installed, Flask-Admin uses it and expects it to be initialised: without Babel(app) every admin page fails with a KeyError: 'babel'. Either initialise Flask-Babel or uninstall it.

import secrets
import tempfile
from pathlib import Path

from flask import Flask, abort, redirect, request, url_for
from flask_admin import Admin, AdminIndexView, BaseView, expose
from flask_admin.contrib.sqla import ModelView
from flask_admin.theme import Bootstrap4Theme
from flask_babel import Babel
from flask_login import LoginManager, UserMixin, current_user
from flask_sqlalchemy import SQLAlchemy
from decimal import Decimal

from sqlalchemy import ForeignKey, Numeric, String, Text, func, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from werkzeug.security import generate_password_hash


class Base(DeclarativeBase):
    pass


db = SQLAlchemy(model_class=Base)
login_manager = LoginManager()


class User(UserMixin, db.Model):
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(60), unique=True)
    password_hash: Mapped[str] = mapped_column(String(255))
    role: Mapped[str] = mapped_column(String(20), default="reader")

    def __str__(self):
        return self.name


class Author(db.Model):
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(80))
    books: Mapped[list["Book"]] = relationship(back_populates="author")

    def __str__(self):
        return self.name


class Book(db.Model):
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(120))
    isbn: Mapped[str] = mapped_column(String(17), default="")
    price: Mapped[Decimal] = mapped_column(Numeric(8, 2), default=Decimal("0"))
    stock: Mapped[int] = mapped_column(default=0)
    status: Mapped[str] = mapped_column(String(10), default="draft")
    description: Mapped[str] = mapped_column(Text, default="")
    author_id: Mapped[int] = mapped_column(ForeignKey("author.id"))
    author: Mapped[Author] = relationship(back_populates="books")
    reviews: Mapped[list["Review"]] = relationship(back_populates="book", cascade="all, delete-orphan")

    def __str__(self):
        return self.title


class Review(db.Model):
    id: Mapped[int] = mapped_column(primary_key=True)
    book_id: Mapped[int] = mapped_column(ForeignKey("book.id"))
    book: Mapped[Book] = relationship(back_populates="reviews")
    rating: Mapped[int] = mapped_column(default=3)
    comment: Mapped[str] = mapped_column(String(200), default="")


@login_manager.user_loader
def load_user(user_id):
    return db.session.get(User, int(user_id))


TEMPLATES = Path(tempfile.mkdtemp())          # stands in for the application's templates/ folder


def make_app(register, **admin_options):
    app = Flask(__name__, template_folder=str(TEMPLATES))
    app.config.update(SECRET_KEY=secrets.token_hex(32), SQLALCHEMY_DATABASE_URI="sqlite://")
    db.init_app(app)
    Babel(app)
    login_manager.init_app(app)
    admin = Admin(name="Bookshelf Admin", theme=Bootstrap4Theme(swatch="cerulean"), **admin_options)
    register(admin)
    admin.init_app(app)

    @app.get("/login")
    def login():
        return "login page"

    with app.app_context():
        db.create_all()
        db.session.add_all([User(id=i, name=n, role=r, password_hash=generate_password_hash(secrets.token_hex(8)))
                            for i, n, r in [(1, "alice", "admin"), (2, "bob", "editor"), (3, "carol", "reader")]])
        db.session.add_all([Author(id=1, name="Ursula Le Guin"), Author(id=2, name="Frank Herbert")])
        db.session.add_all([Book(title="A Wizard of Earthsea", isbn="9780547773742", price=Decimal("9.5"), stock=4, author_id=1),
                            Book(title="Dune", isbn="9780441013593", price=Decimal("12"), stock=0, author_id=2)])
        db.session.commit()
    return app


def log_in_as(client, user_id):
    with client.session_transaction() as session:
        session["_user_id"] = str(user_id)
        session["_fresh"] = True


def first_admin(admin):
    admin.add_view(ModelView(Author, db, category="Catalog"))
    admin.add_view(ModelView(Book, db, category="Catalog"))


app = make_app(first_admin)
anonymous = app.test_client()
for url in ("/admin/", "/admin/author/", "/admin/book/", "/admin/book/new/"):
    print(url, anonymous.get(url).status_code)

All four pages answer 200 to an anonymous client: the admin is open. The default Admin mounts at /admin, builds one menu entry per registered view (grouped by category) and uses the model’s class name as the endpoint, so the book list is /admin/book/ and url_for('book.index_view') builds it.

Table 1. Constructor options of Admin
Parameter Effect

name

Title shown in the navbar and the page title (default Admin).

url, endpoint, subdomain, host

Where the admin is mounted. Changing url moves it away from the guessable /admin; subdomain and host serve it from another host name (see the host-matching topic of the Flask-Admin documentation).

index_view

The landing page: an AdminIndexView instance or subclass instance, used to secure and customise the home page.

theme

A Theme; Bootstrap4Theme(swatch=..., fluid=...) is the one built-in. swatch picks a Bootswatch colour scheme and fluid makes the layout full width.

translations_path

An extra folder of translation catalogues for Flask-Babel.

static_url_path

URL of the admin’s static files.

category_icon_classes

Icons for menu categories.

csp_nonce_generator

A callable returning the Content-Security-Policy nonce to add to inline scripts and styles (since 2.1).

Views can also be added after construction (admin.add_view, admin.add_views), grouped (category= plus admin.add_sub_category), and mixed with plain links and dividers (admin.add_link(MenuLink(...)), admin.add_menu_item(MenuDivider(), ...)). With the application factory pattern, create admin = Admin(...) at module level and call admin.init_app(app) inside create_app(), as the helper above does.

Customising the List View

By default a ModelView lists every mapped column, sorts by primary key and offers no search or filter. Class attributes shape the list page.

Attribute Effect

column_list, column_exclude_list

Which columns appear, and in which order. Relationships can be listed by name.

column_labels, column_descriptions

Header labels and tooltips.

column_sortable_list, column_default_sort

Sortable columns (a (name, 'relation.column') tuple sorts a relation by one of its columns) and the initial order, for example ('title', False).

column_searchable_list

Columns searched by the search box; 'author.name' searches through a relationship. A text search is a LIKE across all listed columns, so do not list unindexed columns of very large tables.

column_filters

Columns offered in the filter menu; the filter types are chosen from the column type. Plain names, related columns ('author.name') and BaseFilter instances are accepted.

column_editable_list

Columns editable in place from the list (X-Editable popovers that POST to ajax_update).

column_formatters, column_type_formatters

Functions (view, context, model, name) that return the HTML or text shown for a column, or for every value of a type.

column_display_pk

Show the primary key (hidden by default).

page_size, can_set_page_size, page_size_options

Rows per page (default 20) and a selector for the user; simple_list_pager avoids the COUNT query.

can_create, can_edit, can_delete, can_view_details

Switch whole operations off or the read-only details page on (can_view_details defaults to False).

can_export, export_types, export_max_rows, column_export_list

CSV export (['csv'] by default; other formats need the export extra) and its limits. Export is a data-leak vector: exclude sensitive columns and cap the rows.

create_modal, edit_modal, details_modal

Open the form in a modal dialog.

get_query(), get_count_query()

Hooks for a persistent filter, for example "an editor sees only their own books". Override both and get_one().

from wtforms import validators


class BookView(ModelView):
    column_list = ["title", "author", "isbn", "price", "stock", "status"]
    column_labels = {"isbn": "ISBN"}
    column_descriptions = {"stock": "Copies in the warehouse"}
    column_searchable_list = ["title", "isbn", "author.name"]
    column_filters = ["status", "price", "stock", "author.name"]
    column_sortable_list = ["title", "price", "stock", ("author", "author.name")]
    column_default_sort = ("title", False)
    column_editable_list = ["stock"]
    column_formatters = {"price": lambda view, context, model, name: f"{model.price:.2f} EUR"}
    can_view_details = True
    can_export = True
    export_types = ["csv"]
    column_export_list = ["title", "isbn", "price", "stock"]
    page_size = 50
    can_set_page_size = True


def customised(admin):
    admin.add_view(ModelView(Author, db, category="Catalog"))
    admin.add_view(BookView(Book, db, category="Catalog"))


app = make_app(customised)
client = app.test_client()
page = client.get("/admin/book/").get_data(as_text=True)
print("Dune" in page, "12.00 EUR" in page)
print("Dune" in client.get("/admin/book/?search=herbert").get_data(as_text=True),
      "Earthsea" in client.get("/admin/book/?search=herbert").get_data(as_text=True))
print(client.get("/admin/book/?sort=3&desc=1").status_code, client.get("/admin/book/export/csv/").data.decode().splitlines()[0])

The first line prints True True (the formatter ran), the second True False (the search for the author name found only Dune through the author.name search column), and the last shows the export header Title,ISBN,Price,Stock. Two caveats: the keys of column_formatters must be columns that are displayed, and a formatter returns text or Markup; returning a raw string built from user data without escaping is an XSS hole, because the cell is rendered as HTML only when you wrap it in Markup (use markupsafe.escape() first).

Customising Forms

The create and edit forms are scaffolded with wtforms-sqlalchemy-style conversion: a String(120) column becomes a StringField with a Length validator, a Text column a TextAreaField, a foreign key a select box, a relationship a multiple select. The form_* attributes override the result.

Attribute Effect

form_columns, form_excluded_columns

The columns in the form, in order, or the ones to drop. Prefer the allow-list form_columns: it keeps a column you add to the model later out of the admin form (mass-assignment protection).

form_args

Extra keyword arguments per field: label, description, validators, default.

form_overrides

Replace the field class of a column with another WTForms field.

form_widget_args

HTML attributes for the widget: {'description': {'rows': 8}}, {'isbn': {'readonly': True}}.

form_choices

Turns a string column into a select box with the given (value, label) pairs.

form_extra_fields

Fields that are not columns (a password input, a confirmation checkbox).

form_ajax_refs

Loads the options of a relationship over AJAX instead of rendering every row into a select: essential for tables with thousands of rows. Takes {'fields': [...], 'page_size': n} per relation.

inline_models

Edit child rows (the reviews of a book) inside the parent’s form.

form_create_rules, form_edit_rules, form_rules

The layout: rules.FieldSet, rules.Header, rules.Row, rules.Text, rules.HTML, rules.Macro and others from flask_admin.form.rules.

form_base_class

The base WTForms class; SecureForm switches CSRF protection on (see the security section).

from flask_admin.form import rules


class BookFormView(BookView):
    form_columns = ["title", "author", "isbn", "price", "stock", "status", "description", "reviews"]
    form_args = {
        "isbn": {"validators": [validators.Optional(), validators.Length(min=10, max=17)]},
        "price": {"validators": [validators.NumberRange(min=0)]},
    }
    form_choices = {"status": [("draft", "Draft"), ("published", "Published")]}
    form_widget_args = {"description": {"rows": 8}}
    form_ajax_refs = {"author": {"fields": ["name"], "page_size": 10}}
    inline_models = [(Review, {"form_columns": ["id", "rating", "comment"]})]
    form_create_rules = [
        rules.FieldSet(["title", "author", "isbn"], "Identity"),
        rules.FieldSet(["price", "stock", "status"], "Sales"),
        "description",
        "reviews",
    ]
    form_edit_rules = form_create_rules


def with_forms(admin):
    admin.add_view(ModelView(Author, db))
    admin.add_view(BookFormView(Book, db))


app = make_app(with_forms)
client = app.test_client()
form = client.get("/admin/book/new/").get_data(as_text=True)
print("Identity" in form, 'name="status"' in form, 'rows="8"' in form)
created = client.post("/admin/book/new/", data={"title": "The Dispossessed", "author": "1", "isbn": "9780061054884",
                                                  "price": "10", "stock": "3", "status": "published"})
print(created.status_code)
rejected = client.post("/admin/book/new/", data={"title": "Bad", "author": "1", "isbn": "123", "price": "-1", "status": "draft"})
print(rejected.status_code, "Field must be between 10 and 17 characters long." in rejected.get_data(as_text=True))
with app.app_context():
    print(db.session.scalars(select(Book.title).order_by(Book.id)).all())

The create form is a regular page, the valid POST answers 302 and the invalid one redisplays the form with a 200 and the WTForms messages; only the valid book was stored. Notes on the options:

  • inline_models accepts model classes or (Model, options) tuples; the options dictionary takes the same form_columns, form_args, form_label and similar keys as a view. The inline rows are saved with the parent in one transaction, and removing a row deletes the child only if the relationship cascade allows it (cascade="all, delete-orphan" here).

  • Validators come from WTForms; custom ones are plain callables (form, field) that raise wtforms.validators.ValidationError.

  • Cross-field or database rules (a unique pair, a stock level that depends on the status) do not fit a field validator: raise wtforms.validators.ValidationError from on_model_change(), as the next example does.

  • A form for a model with a large foreign key needs form_ajax_refs; otherwise the select renders every author into the HTML.

Hooks, Business Rules and Batch Actions

on_model_change(form, model, is_created) runs after the form populated the model and before the commit, in the same transaction; after_model_change() runs after a successful commit; on_model_delete() and after_model_delete() do the same for deletion. Raising wtforms.validators.ValidationError from on_model_change() or on_model_delete() flashes the message and cancels the operation, which is how to protect a record from deletion.

Batch actions are methods decorated with @action(name, text, confirmation). They receive the list of selected primary keys as strings, and the list page shows them in an "With selected" menu. The built-in delete action exists whenever can_delete is true; removing it is done with action_disallowed_list = ["delete"].

from flask import flash
from flask_admin.actions import action
from wtforms.validators import ValidationError


class PublishingBookView(BookFormView):
    action_disallowed_list = ["delete"]

    def on_model_change(self, form, model, is_created):
        model.isbn = model.isbn.replace("-", "").strip()
        if model.status == "published" and model.stock < 0:
            raise ValidationError("A published book cannot have negative stock.")

    def on_model_delete(self, model):
        if model.status == "published":
            raise ValidationError("Unpublish the book before deleting it.")

    @action("publish", "Publish", "Publish the selected books?")
    def action_publish(self, ids):
        books = db.session.scalars(select(Book).where(Book.id.in_([int(i) for i in ids]))).all()
        for book in books:
            book.status = "published"
        db.session.commit()
        flash(f"{len(books)} book(s) published.")


def with_actions(admin):
    admin.add_view(ModelView(Author, db))
    admin.add_view(PublishingBookView(Book, db))


app = make_app(with_actions)
client = app.test_client()
reply = client.post("/admin/book/action/", data={"action": "publish", "rowid": ["1", "2"]}, follow_redirects=True)
print(reply.status_code, "2 book(s) published." in reply.get_data(as_text=True))
with app.app_context():
    print(db.session.scalars(select(Book.status).order_by(Book.id)).all())
blocked = client.post("/admin/book/delete/", data={"id": "1", "url": "/admin/book/"}, follow_redirects=True)
print("Unpublish the book before deleting it." in blocked.get_data(as_text=True))

The prints are 200 True, ['published', 'published'] and True. A few details matter in practice: the delete form of a view with form_base_class = SecureForm carries a CSRF token, so scripted calls like the one above fail on a secured admin (the test section shows how to fetch the token); an action method that does heavy work should enqueue a background job instead of running in the request (see the background-jobs material of this section and the scheduling section); and the actions run with the permissions of whoever is logged in, so an action is part of the attack surface and must be hidden from roles that may not use it (is_action_allowed() can veto a single action by name).

Custom Pages with BaseView and AdminIndexView

Not everything is a table. BaseView is the building block for a free-form page: methods decorated with @expose(url, methods=...) become routes, self.render(template, **context) renders a template that normally extends admin/master.html, and the view appears in the menu. AdminIndexView is a BaseView whose root is the admin’s home page; passing a subclass instance as Admin(index_view=...) customises or secures that page.

The template below would live in the application’s templates/admin/ folder; the example writes it to the temporary template folder of make_app().

(TEMPLATES / "admin").mkdir(exist_ok=True)
(TEMPLATES / "admin" / "stats.html").write_text(
    "{% extends 'admin/master.html' %}\n"
    "{% block body %}\n"
    "<h1>Catalog statistics</h1>\n"
    "<ul>{% for status, total in counts %}<li>{{ status }}: {{ total }}</li>{% endfor %}</ul>\n"
    "{% endblock %}\n"
)


class StatsView(BaseView):
    @expose("/")
    def index(self):
        counts = db.session.execute(select(Book.status, func.count()).group_by(Book.status).order_by(Book.status)).all()
        return self.render("admin/stats.html", counts=counts)

    @expose("/reset/", methods=["POST"])
    def reset(self):
        return redirect(url_for(".index"))


class HomeView(AdminIndexView):
    @expose("/")
    def index(self):
        return self.render("admin/index.html")


def with_pages(admin):
    admin.add_view(ModelView(Author, db))
    admin.add_view(PublishingBookView(Book, db))
    admin.add_view(StatsView(name="Statistics", endpoint="stats", category="Reports"))


app = make_app(with_pages, index_view=HomeView(name="Dashboard", url="/admin"))
client = app.test_client()
print(client.get("/admin/stats/").status_code, "draft: 2" in client.get("/admin/stats/").get_data(as_text=True))
print(client.get("/admin/").status_code)

The statistics page lists the books per status (draft: 2). Remember that a BaseView is as open as a ModelView: it needs its own is_accessible(). expose_plugview and Admin.add_view also accept class-based MethodView pages for admin screens that behave like an API. Menu entries can be hidden without making the view inaccessible by overriding is_visible(); a hidden view is still reachable by URL, so is_visible is cosmetics, not security.

Securing the Admin

Flask-Admin calls _handle_view() before every exposed method of a view. It checks is_accessible() and, when that returns False, returns the result of inaccessible_callback(), which by default aborts with 403. Securing the admin means overriding both on every view, including the index view, BaseView pages and FileAdmin. A mixin avoids forgetting one, and it plugs the same roles as the rest of the application into the admin (the roles model is the one of the authorization page; Flask-Login supplies current_user, see the authentication page).

class SecureMixin:
    allowed_roles = {"admin"}

    def is_accessible(self):
        return current_user.is_authenticated and current_user.role in self.allowed_roles

    def inaccessible_callback(self, name, **kwargs):
        if not current_user.is_authenticated:
            return redirect(url_for("login", next=request.url))      # anonymous: send to the login page
        abort(403)                                                    # signed in but not allowed


class SecureIndexView(SecureMixin, AdminIndexView):
    allowed_roles = {"admin", "editor"}


class SecureStatsView(SecureMixin, StatsView):
    allowed_roles = {"admin", "editor"}


class SecureModelView(SecureMixin, ModelView):
    pass


class SecureBookView(SecureMixin, PublishingBookView):
    allowed_roles = {"admin", "editor"}


class UserAdminView(SecureModelView):
    column_exclude_list = ["password_hash"]
    form_columns = ["name", "role"]
    form_choices = {"role": [("reader", "Reader"), ("editor", "Editor"), ("admin", "Admin")]}
    can_create = False                      # users sign up through the application, not here

    def on_model_change(self, form, model, is_created):
        if model.id == current_user.id and model.role != "admin":
            raise ValidationError("You cannot remove your own admin role.")


def secured(admin):
    admin.add_view(SecureModelView(Author, db))
    admin.add_view(SecureBookView(Book, db))
    admin.add_view(UserAdminView(User, db, category="Access"))
    admin.add_view(SecureStatsView(name="Statistics", endpoint="stats", category="Reports"))


app = make_app(secured, index_view=SecureIndexView(name="Dashboard", url="/admin"))
anonymous, bob, alice = app.test_client(), app.test_client(), app.test_client()
log_in_as(bob, 2)
log_in_as(alice, 1)
for url in ("/admin/", "/admin/book/", "/admin/user/", "/admin/stats/"):
    print(url, anonymous.get(url).status_code, bob.get(url).status_code, alice.get(url).status_code)
print(anonymous.get("/admin/book/").headers["Location"])

Each row prints the status for an anonymous visitor, for Bob (an editor) and for Alice (an administrator): /admin/ and /admin/book/ give 302 200 200, /admin/user/ gives 302 403 200 and the statistics page 302 200 200. The anonymous request is redirected to /login?next=..., so after signing in the user lands back on the admin page. Notes:

  • Because inaccessible_callback() is a method, you can answer an AJAX call with 401 JSON instead of a redirect (inspect request.accept_mimetypes or request.is_json).

  • is_accessible() runs on every request, so read the role from the database or a cached user object, not from a cookie the user can edit. The Flask-Login session cookie is signed with SECRET_KEY; a leaked key lets an attacker forge _user_id.

  • With Flask-Security-Too, the Flask-Admin documentation shows a context processor that exposes admin_base_template, admin_view, theme and the admin helpers to the Flask-Security templates, so that its login pages render inside the admin theme; access is again decided in is_accessible(), with current_user.has_role('admin'). The authorization page covers Flask-Security itself.

  • Restrict what a role sees, not only whether it can open the view: override get_query(), get_count_query() and get_one() for per-owner filters, and set can_create, can_edit, can_delete and can_export per role. Remember that column_exclude_list only hides a column from the list; the form must also leave it out (form_columns), as the UserAdminView does with password_hash.

  • Reduce exposure further: mount the admin under a non-default url, require two-factor authentication and HTTPS for admin roles, rate-limit the login page, and log who changed what (after_model_change() and after_model_delete() are the natural places).

CSRF Protection

The delete, batch-action and form POSTs of the admin change data, so they need CSRF protection. Flask-Admin’s forms are not protected by default; setting form_base_class = SecureForm (from flask_admin.form) on a view enables a WTForms SessionCSRF token on its create, edit, delete and action forms. The token is derived from SECRET_KEY and stored with the session and is checked when the form is validated. It works without Flask-WTF. Combining it with a global Flask-WTF CSRFProtect needs care: the two tokens are different, and with CSRFProtect(app) enabled a POST to an admin form that lacks Flask-WTF’s token is answered 400 before the view runs. Decide which mechanism guards the admin routes, and test the create, edit, delete and batch-action forms of the result.

import re
from flask_admin.form import SecureForm


class CsrfBookView(SecureBookView):
    form_base_class = SecureForm
    form_create_rules = [*BookFormView.form_create_rules, "csrf_token"]   # rules must list the token field
    form_edit_rules = form_create_rules


def csrf_protected(admin):
    admin.add_view(SecureModelView(Author, db))
    admin.add_view(CsrfBookView(Book, db))


app = make_app(csrf_protected)
alice = app.test_client()
log_in_as(alice, 1)
payload = {"title": "Lavinia", "author": "1", "isbn": "9780547573052", "price": "8", "stock": "1", "status": "draft"}
without = alice.post("/admin/book/new/", data=payload)
print(without.status_code, "CSRF" in without.get_data(as_text=True))
token = re.search(r'name="csrf_token"[^>]*value="([^"]+)"', alice.get("/admin/book/new/").get_data(as_text=True)).group(1)
with_token = alice.post("/admin/book/new/", data={**payload, "csrf_token": token})
print(with_token.status_code)

Without the token the form is redisplayed with a CSRF error (200 True); with it the book is created and the answer is 302. A mixin that sets form_base_class = SecureForm for every view is the safest way to apply it everywhere.

There is a trap with form rules. When a view defines form_rules, form_create_rules or form_edit_rules, Flask-Admin removes every form field that is not named in the rules, and the CSRF token is a form field. Without "csrf_token" in the rules, the token is neither rendered nor checked, so the form is silently unprotected: a forged POST without any token is accepted and answered with 302. The only symptom is a UserWarning ("Fields missing from ruleset: csrf_token") at start-up, so treat that warning as an error in tests (python -W error::UserWarning, or pytest’s filterwarnings).

Content Security Policy

The built-in templates contain inline scripts and styles. A strict CSP (script-src 'self' without unsafe-inline) would block them, so since Flask-Admin 2.1 Admin(csp_nonce_generator=...) takes a callable that returns the per-request nonce, and the templates add nonce="..." to their inline tags. You remain responsible for sending a Content-Security-Policy header with the same nonce; Flask-Talisman (see the security page of this section) generates both.

from flask_talisman import Talisman

csp_app = Flask(__name__)
csp_app.config.update(SECRET_KEY=secrets.token_hex(32), SQLALCHEMY_DATABASE_URI="sqlite://")
db.init_app(csp_app)
Babel(csp_app)
Talisman(csp_app, force_https=False,
         content_security_policy={"default-src": "'self'", "script-src": "'self'", "style-src": "'self'"},
         content_security_policy_nonce_in=["script-src", "style-src"])
csp_admin = Admin(csp_app, name="Bookshelf Admin", theme=Bootstrap4Theme(),
                  csp_nonce_generator=csp_app.jinja_env.globals["csp_nonce"])
csp_admin.add_view(ModelView(Author, db))
with csp_app.app_context():
    db.create_all()
reply = csp_app.test_client().get("/admin/author/")
policy = reply.headers["Content-Security-Policy"]
nonce = re.search(r"'nonce-([^']+)'", policy).group(1)
print(reply.status_code, f'nonce="{nonce}"' in reply.get_data(as_text=True))

The output is 200 True: the nonce of the header is the one in the page’s inline tags. Talisman appends the nonce only to directives that exist in the policy, which is why script-src and style-src are spelled out; with only default-src the header would carry no nonce and the inline tags would be blocked. (This example leaves the admin open to keep it short; combine it with the mixin above.) Third-party scripts such as an editor loaded from a CDN need their own script-src entry, which is another reason to self-host them.

Rich-Text Editing and HTML Sanitising

A book description edited by staff is the classic case for a WYSIWYG editor. The cookbook (chapter 8) and the Flask-Admin documentation’s own example wire CKEditor 4 into a ModelView. That editor reached end of life on 30 June 2023: no new features or security fixes, except for CKSource’s paid extended support, and the Flask-Admin example still loads version 4.6.0 from a CDN. Do not copy it.

The wiring is independent of the editor. A custom WTForms field supplies a CSS class, form_overrides swaps the field for the description column, and extra_js and extra_css load the editor’s files in the admin pages.

from markupsafe import Markup
from wtforms import TextAreaField
from wtforms.widgets import TextArea


class RichTextWidget(TextArea):
    def __call__(self, field, **kwargs):
        kwargs["class"] = f"{kwargs.get('class', '')} rich-text".strip()
        return super().__call__(field, **kwargs)


class RichTextField(TextAreaField):
    widget = RichTextWidget()


class RichTextBookView(SecureBookView):
    form_overrides = {"description": RichTextField}
    extra_css = ["/static/vendor/ckeditor5/ckeditor5.css"]
    extra_js = ["/static/vendor/ckeditor5/ckeditor5.umd.js", "/static/admin/rich-text.js"]


def rich_text(admin):
    admin.add_view(SecureModelView(Author, db))
    admin.add_view(RichTextBookView(Book, db))


app = make_app(rich_text)
alice = app.test_client()
log_in_as(alice, 1)
page = alice.get("/admin/book/new/").get_data(as_text=True)
print('class="form-control rich-text"' in page or "rich-text" in page, "/static/vendor/ckeditor5/ckeditor5.umd.js" in page)

The page carries the rich-text class on the textarea and loads the two script files (True True). The script is plain JavaScript that turns every textarea.rich-text into an editor. With CKEditor 5 (the supported successor) it looks like the snippet below, using the self-hosted UMD bundle. Since version 44 CKEditor 5 requires a licenseKey: the value 'GPL' is for GPL-compatible projects, and a commercial licence key otherwise, so check the licence before shipping.

// static/admin/rich-text.js
document.querySelectorAll("textarea.rich-text").forEach((textarea) => {
  const { ClassicEditor, Essentials, Paragraph, Bold, Italic } = window.CKEDITOR;
  ClassicEditor.create(textarea, {
    licenseKey: "GPL",
    plugins: [Essentials, Paragraph, Bold, Italic],
    toolbar: ["undo", "redo", "|", "bold", "italic"],
  }).catch((error) => console.error(error));
});

Further toolbar items (headings, lists, links) come from additional plugins of the same bundle, listed in the CKEditor 5 documentation. Other maintained editors work with the same wiring, and the choice is a licensing and feature decision: TinyMCE, Quill, Trix, Tiptap or a Markdown textarea with a server-side renderer. A Markdown field is the smallest attack surface when staff do not need a visual editor.

Never Trust the Editor: Sanitise on the Server

An editor produces HTML, and the admin form field accepts whatever is POSTed, whether or not it came from the editor. If the description is later rendered with {{ book.description|safe }}, an attacker who can write one description (a compromised editor account, an API client, a forged request) runs script in every visitor’s browser: stored XSS. The fix is to clean the HTML on the server with an allow-list sanitiser before it is stored, in on_model_change().

The two Python libraries you will meet differ in status. bleach used to be the default answer, but its documentation now states that, as of 2026-06-05, it is no longer maintained and will receive no releases, not even for security issues; its last release is 6.4.0. nh3 (Python bindings to the Rust ammonia sanitiser) is the maintained replacement: its nh3.clean() takes tags, attributes, url_schemes, link_rel and more, and it adds rel="noopener noreferrer" to links by default.

import nh3

ALLOWED_TAGS = {"p", "br", "strong", "em", "u", "s", "ul", "ol", "li", "blockquote", "h2", "h3", "a", "code", "pre"}
ALLOWED_ATTRIBUTES = {"a": {"href", "title"}}


def sanitize_html(html):
    return nh3.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES, url_schemes={"http", "https", "mailto"})


class SafeRichTextBookView(RichTextBookView):
    def on_model_change(self, form, model, is_created):
        super().on_model_change(form, model, is_created)
        model.description = sanitize_html(model.description)


def safe_rich_text(admin):
    admin.add_view(SecureModelView(Author, db))
    admin.add_view(SafeRichTextBookView(Book, db))


dirty = '<p onclick="steal()">Hello <strong>world</strong><script>alert(1)</script><a href="javascript:steal()">x</a></p>'
print(sanitize_html(dirty))
app = make_app(safe_rich_text)
alice = app.test_client()
log_in_as(alice, 1)
alice.post("/admin/book/new/", data={"title": "Lavinia", "author": "1", "price": "8", "stock": "1",
                                       "status": "draft", "description": dirty})
with app.app_context():
    print(db.session.scalar(select(Book.description).where(Book.title == "Lavinia")))

The sanitiser removes the event handler, the script element and the javascript: URL, leaving <p>Hello <strong>world</strong><a rel="noopener noreferrer">x</a></p>, and the admin stores that. Three further rules: sanitise at every write path (admin, API, import scripts), not only the admin; keep the allow-list small and add attributes only when needed (style and class are not in it on purpose); and render with |safe only values that were sanitised on the way in (Markup(book.description) is the Python equivalent). The Jinja page of this section explains autoescaping and Markup.

Managing Files and Images

flask_admin.contrib.fileadmin.FileAdmin gives the admin a file manager for a directory: list, upload, download, rename, create folders and delete, each switchable with can_upload, can_download, can_delete, can_delete_dirs, can_mkdir and can_rename. allowed_extensions limits what can be uploaded and editable_extensions lists text extensions that open in an in-browser editor. For a single image column, form_overrides with Flask-Admin’s ImageUploadField (needs the images extra, i.e. Pillow) stores the file and a thumbnail and keeps the file name in the column. The upload rules (size limits, secure_filename, validating the content, not the extension) are the topic of the file uploads page; they apply unchanged here.

from flask_admin.contrib.fileadmin import FileAdmin


class CoverFiles(SecureMixin, FileAdmin):
    allowed_extensions = {"jpg", "jpeg", "png", "webp"}
    can_delete_dirs = False
    can_mkdir = False
    can_rename = False


covers = tempfile.mkdtemp()


def with_files(admin):
    admin.add_view(CoverFiles(covers, "/covers/", name="Covers", endpoint="covers"))


app = make_app(with_files)
alice, anonymous = app.test_client(), app.test_client()
log_in_as(alice, 1)
print(anonymous.get("/admin/covers/").status_code, alice.get("/admin/covers/").status_code)

The anonymous request is redirected (302) and the administrator sees the (empty) file list (200). A FileAdmin is the most dangerous admin view to leave open: it writes to the file system. Point it at a dedicated directory, never at the project root, and never allow executable extensions.

Configuration Keys

Since 2.0 all Flask-Admin settings live under the FLASK_ADMIN_ prefix in app.config.

Key Effect

FLASK_ADMIN_RAISE_ON_VIEW_EXCEPTION

Raise exceptions from create, update and delete instead of flashing the message. The default flashes and hides the exception outside debug mode; raising them is better in tests and when an error tracker should see them.

FLASK_ADMIN_RAISE_ON_INTEGRITY_ERROR

The same for SQLAlchemy IntegrityError (duplicate keys, constraint violations).

FLASK_ADMIN_MAPS, FLASK_ADMIN_DEFAULT_CENTER_LAT, FLASK_ADMIN_DEFAULT_CENTER_LONG

Map widgets for geographic columns (GeoAlchemy extra).

FLASK_ADMIN_MAPBOX_MAP_ID, FLASK_ADMIN_MAPBOX_ACCESS_TOKEN

MapBox integration. The token is a secret: read it from the environment.

FLASK_ADMIN_MAPS_SEARCH, FLASK_ADMIN_GOOGLE_MAPS_API_KEY

Place search on maps; the API key is a secret as well.

Testing an Admin

The admin is a set of normal Flask routes, so app.test_client() tests it. The tests that matter most are the negative ones: every role that must not enter does not, and every state-changing request without a token is refused. The make_app() and log_in_as() helpers above are the fixtures; with pytest they become fixtures in conftest.py.

def test_admin_access_matrix():
    app = make_app(secured)
    anonymous, bob, carol = app.test_client(), app.test_client(), app.test_client()
    log_in_as(bob, 2)
    log_in_as(carol, 3)
    assert anonymous.get("/admin/book/").status_code == 302
    assert carol.get("/admin/book/").status_code == 403       # a reader has no admin access
    assert bob.get("/admin/book/").status_code == 200
    assert bob.get("/admin/user/").status_code == 403         # editors cannot manage users


def test_csrf_token_is_required():
    app = make_app(csrf_protected)
    alice = app.test_client()
    log_in_as(alice, 1)
    reply = alice.post("/admin/book/delete/", data={"id": "1", "url": "/admin/book/"}, follow_redirects=True)
    with app.app_context():
        assert db.session.get(Book, 1) is not None            # the book survived the forged request


test_admin_access_matrix()
test_csrf_token_is_required()
print("admin tests passed")

Both tests pass. Set FLASK_ADMIN_RAISE_ON_VIEW_EXCEPTION to True in the test configuration so that a failing hook fails the test instead of becoming a flash message. Flask-Admin’s own test-suite pattern is the same: build an Admin on a throw-away application and drive the views with the test client.

Pitfalls and Security

  • The admin is open by default. Forgetting is_accessible() on one view (the index view, a BaseView, a FileAdmin) exposes that view. Use a mixin and a test that lists every registered view and asserts anonymous access is refused.

  • Do not rely on hiding. is_visible(), an obscure url and column_exclude_list hide things from the menu or list only.

  • Rules drop the CSRF token. A view with form_rules, form_create_rules or form_edit_rules must list "csrf_token" in them, otherwise SecureForm is silently disabled for that view.

  • Mass assignment. Without form_columns, every new column of a model appears in the admin form (including is_admin). Allow-list the fields.

  • Passwords. Never edit password_hash as a plain field. Hash in on_model_change() with Werkzeug, as the authentication page shows, or leave password changes to the application’s own flow.

  • Formatters and HTML. A column_formatters function that returns an unescaped string is an XSS vector; escape user data or use Markup only on trusted strings.

  • Large tables. Use form_ajax_refs for big foreign keys, keep column_searchable_list to indexed columns, and cap export_max_rows.

  • Session objects and sessions. Pass db (not db.session) to the view; mixing a separate scoped_session with Flask-SQLAlchemy’s leads to stale reads.

  • Flask-Babel installed but not initialised breaks every page (KeyError: 'babel').

  • Production deployment. Serve the admin only over HTTPS, behind the same SECRET_KEY management as the rest of the application, never run the development server or the debugger in production, and keep the admin out of public caches (Cache-Control: no-store).

  • Static files. The built-in theme serves jQuery, Bootstrap and select2 from its own static folder (the only external script is Google Maps, when map widgets are enabled), which helps with CSP; third-party additions should be self-hosted too.

What Changed and Where Older Material Is Outdated

Table 2. Flask-Admin changes that affect older code
Version Change

2.0.0

Python versions below 3.10 dropped; Bootstrap 2 and 3 themes removed and Admin(template_mode=...) replaced by Admin(theme=Bootstrap4Theme(...)); all configuration keys moved under the FLASK_ADMIN_ prefix; support for Flask-BabelEx removed (use Flask-Babel); MongoEngine support no longer uses the Flask-MongoEngine package; S3FileAdmin and S3Storage moved to boto3 and take an s3_client, Azure storage moved to the v12 SDK; compatibility with SQLAlchemy 2, Flask 3, WTForms 3 and Pillow 10 added.

2.1.0

ModelView(Model, db.session) deprecated in favour of ModelView(Model, db); Flask-SQLAlchemy-Lite support; CSP nonce support (csp_nonce_generator); Bootstrap updated to 4.6.2.

2.2.0 and 2.2.1

Bug fixes and type hints; no breaking changes.

Where the books are outdated:

  • The Flask Framework Cookbook (chapter 8) shows Flask-Admin 1.x, whose Admin(app, template_mode='bootstrap3') and ModelView(Model, db.session) forms are gone or deprecated, and relies on a boolean is_admin attribute checked inside is_accessible(); a roles table is better (see the authorization page).

  • Its WYSIWYG example (and the Flask-Admin documentation’s own example) uses CKEditor 4, which is end of life; use CKEditor 5 or another maintained editor, and always sanitise on the server with a maintained library (nh3), because bleach is no longer maintained.

  • Older admin code often builds queries with Model.query; with Flask-SQLAlchemy 3.1 and SQLAlchemy 2 prefer db.session.scalars(select(...)) in your own hooks and actions (Flask-Admin’s internals still use the session query API, which keeps working).

  • Tutorials that call Admin(app) and stop there leave the admin open to the world; none of the 1.x quick-starts secure it.

Summary

  • A hand-written admin is fine for one or two screens; Flask-Admin generates list, create, edit, delete, search, filter and export from the models and is extended through class attributes and hooks.

  • Register ModelView(Model, db) inside Admin(theme=Bootstrap4Theme(...)); initialise Flask-Babel if it is installed.

  • Shape lists with column_*, forms with form_*, business rules with on_model_change() and batch work with @action.

  • Pages that are not tables are BaseView subclasses with @expose; the home page is an AdminIndexView.

  • The admin is open until is_accessible() says otherwise: use a role mixin on every view, SecureForm for CSRF, a CSP nonce under a strict policy and a deny-by-default test.

  • Use CKEditor 5 or another maintained editor, never CKEditor 4, and sanitise on the server with nh3 (bleach is unmaintained).