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 (/adminby default). AModelViewis aBaseViewwhose 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.ModelViewreads 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); theModelViewattributes are common to all of them.
The request path of a list page shows where each customisation hook sits.
Saving a record follows a second path, and the hooks on_model_change and after_model_change are where business
rules belong.
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: |
List features (search, sort, filters, paging, export) |
Written and tested by you. |
Class attributes: |
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 |
Access control |
Whatever you write: you start from nothing, which is safe by default. |
Open by default: you must override |
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 |
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
Source: Flask-Admin documentation
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(...)takestheme=Bootstrap4Theme(...). The 1.x argumenttemplate_mode='bootstrap3'no longer exists. -
The view is constructed as
ModelView(Book, db), passing the Flask-SQLAlchemy object. Passingdb.session, as most tutorials still do, is deprecated since 2.1 and emits aDeprecationWarning. -
When Flask-Babel is installed, Flask-Admin uses it and expects it to be initialised: without
Babel(app)every admin page fails with aKeyError: '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)
Source: Flask-Admin: Introduction
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.
| Parameter | Effect |
|---|---|
|
Title shown in the navbar and the page title (default |
|
Where the admin is mounted. Changing |
|
The landing page: an |
|
A |
|
An extra folder of translation catalogues for Flask-Babel. |
|
URL of the admin’s static files. |
|
Icons for menu categories. |
|
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 |
|---|---|
|
Which columns appear, and in which order. Relationships can be listed by name. |
|
Header labels and tooltips. |
|
Sortable columns (a |
|
Columns searched by the search box; |
|
Columns offered in the filter menu; the filter types are chosen from the column type. Plain names, related columns
( |
|
Columns editable in place from the list (X-Editable popovers that POST to |
|
Functions |
|
Show the primary key (hidden by default). |
|
Rows per page (default |
|
Switch whole operations off or the read-only details page on ( |
|
CSV export ( |
|
Open the form in a modal dialog. |
|
Hooks for a persistent filter, for example "an editor sees only their own books". Override both and |
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 |
|---|---|
|
The columns in the form, in order, or the ones to drop. Prefer the allow-list |
|
Extra keyword arguments per field: |
|
Replace the field class of a column with another WTForms field. |
|
HTML attributes for the widget: |
|
Turns a string column into a select box with the given |
|
Fields that are not columns (a password input, a confirmation checkbox). |
|
Loads the options of a relationship over AJAX instead of rendering every row into a select: essential for tables
with thousands of rows. Takes |
|
Edit child rows (the reviews of a book) inside the parent’s form. |
|
The layout: |
|
The base WTForms class; |
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_modelsaccepts model classes or(Model, options)tuples; the options dictionary takes the sameform_columns,form_args,form_labeland 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 raisewtforms.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.ValidationErrorfromon_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 with401JSON instead of a redirect (inspectrequest.accept_mimetypesorrequest.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 withSECRET_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,themeand the admin helpers to the Flask-Security templates, so that its login pages render inside the admin theme; access is again decided inis_accessible(), withcurrent_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()andget_one()for per-owner filters, and setcan_create,can_edit,can_deleteandcan_exportper role. Remember thatcolumn_exclude_listonly hides a column from the list; the form must also leave it out (form_columns), as theUserAdminViewdoes withpassword_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()andafter_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))
Source: Flask-Admin: CSP support
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")))
Source: nh3 documentation
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 |
|---|---|
|
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. |
|
The same for SQLAlchemy |
|
Map widgets for geographic columns (GeoAlchemy extra). |
|
MapBox integration. The token is a secret: read it from the environment. |
|
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, aBaseView, aFileAdmin) 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 obscureurlandcolumn_exclude_listhide things from the menu or list only. -
Rules drop the CSRF token. A view with
form_rules,form_create_rulesorform_edit_rulesmust list"csrf_token"in them, otherwiseSecureFormis silently disabled for that view. -
Mass assignment. Without
form_columns, every new column of a model appears in the admin form (includingis_admin). Allow-list the fields. -
Passwords. Never edit
password_hashas a plain field. Hash inon_model_change()with Werkzeug, as the authentication page shows, or leave password changes to the application’s own flow. -
Formatters and HTML. A
column_formattersfunction that returns an unescaped string is an XSS vector; escape user data or useMarkuponly on trusted strings. -
Large tables. Use
form_ajax_refsfor big foreign keys, keepcolumn_searchable_listto indexed columns, and capexport_max_rows. -
Session objects and sessions. Pass
db(notdb.session) to the view; mixing a separatescoped_sessionwith 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_KEYmanagement 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
| Version | Change |
|---|---|
2.0.0 |
Python versions below 3.10 dropped; Bootstrap 2 and 3 themes removed and |
2.1.0 |
|
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')andModelView(Model, db.session)forms are gone or deprecated, and relies on a booleanis_adminattribute checked insideis_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 preferdb.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)insideAdmin(theme=Bootstrap4Theme(...)); initialise Flask-Babel if it is installed. -
Shape lists with
column_*, forms withform_*, business rules withon_model_change()and batch work with@action. -
Pages that are not tables are
BaseViewsubclasses with@expose; the home page is anAdminIndexView. -
The admin is open until
is_accessible()says otherwise: use a role mixin on every view,SecureFormfor 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).