Forms with Flask-WTF
|
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 (released 2026-02-18), Flask-WTF 1.3.0 (released 2026-04-23), WTForms 3.2.2,
wtforms-sqlalchemy 0.4.2 and Werkzeug 3.1.9, and was checked on 2026-10-07. Flask 3.2 was not released on that date, so
anything described as "3.2" is upcoming. The code uses Python 3.10+ syntax and every example was executed on Python 3.13
with app.test_client(). A web form is the one place where a stranger types into your application on purpose, so a form
library has three jobs: turn strings into Python values, decide whether the values are acceptable, and prove that the
request was meant to be sent by the user who sent it. WTForms does the first two, Flask-WTF connects WTForms to Flask’s
request, session and configuration and adds the third (CSRF protection). The scenario is the Bookshelf catalogue that the
other pages of this section build on: a form to add a book, a form to review it and a form to upload its cover.
Why Use a Form Library
The Flask request object already gives you everything that was submitted (see Request Data), so a form library is not strictly necessary. The following view handles a book form by hand:
from flask import Flask, request
raw_app = Flask("raw")
@raw_app.post("/books")
def create_book_raw():
title = request.form.get("title", "").strip()
pages = request.form.get("pages", "")
errors = {}
if not title:
errors["title"] = "Title is required."
elif len(title) > 120:
errors["title"] = "Title is too long."
if not pages.isdecimal() or not 1 <= int(pages) <= 5000:
errors["pages"] = "Pages must be a number between 1 and 5000."
if errors:
return {"errors": errors}, 422
return {"title": title, "pages": int(pages)}, 201
with raw_app.test_client() as client:
print(client.post("/books", data={"title": "", "pages": "abc"}).get_json())
print(client.post("/books", data={"title": "Dune", "pages": "412"}).get_json())
This works for two fields and then stops scaling. Every field needs its own conversion, its own error messages, its own re-display of what the user typed, its own HTML and, on every state-changing request, a CSRF check that is easy to forget. WTForms puts those concerns in one declarative class:
-
Declaration. A form is a class whose attributes are fields; each field carries its type, label, validators and filters.
-
Processing. The form reads the submitted data, converts it (
IntegerFieldyields anint,DateFieldadate) and keeps the raw input so the page can be re-rendered with what the user typed. -
Validation. Validators run per field and collect error messages in
form.errors. -
Rendering. Each field renders itself as HTML through a widget and can be given extra attributes, so the template never repeats names, ids or values.
-
Security. Flask-WTF adds a CSRF token field to every
FlaskFormand validates it as part ofvalidate().
Which library does what is worth knowing, because the documentation is split:
| Package | Responsibility |
|---|---|
WTForms |
|
Flask-WTF |
|
ItsDangerous |
Signs and time-stamps the CSRF token (the same library that signs the session cookie). |
Werkzeug |
Supplies |
wtforms-sqlalchemy |
|
Form handling in other frameworks is described in Django Forms and in the FastAPI section, where Pydantic models play the role of the form class. A Flask application that exposes only a JSON API does not need WTForms; it needs a schema library instead (see the REST API pages of this section).
Installing
pip install flask-wtf # pulls in WTForms
pip install "flask-wtf[email]" # adds email-validator, required by the Email validator
pip install wtforms-sqlalchemy # QuerySelectField, model_form (optional)
Source: Flask-WTF: Installation
The Email validator imports email_validator at validation time and raises an exception when the package is missing, so
install the email extra whenever a form validates addresses. The Flask-WTF documentation site still opens on its
1.2.x version in October 2026 although 1.3.0 is the current release; the 1.3.0 behaviour described below was confirmed
against the installed package.
FlaskForm, Fields and Validators
A form is a subclass of flask_wtf.FlaskForm. Fields come from wtforms and validators from wtforms.validators:
import os
import secrets
from flask import Flask, flash, redirect, render_template_string, request, url_for
from flask_wtf import FlaskForm
from wtforms import BooleanField, IntegerField, StringField, SubmitField, TextAreaField
from wtforms.validators import InputRequired, Length, NumberRange, Optional, Regexp
app = Flask(__name__)
# Read the key from the environment; fall back to a random per-process key for local runs only.
app.config["SECRET_KEY"] = os.environ.get("BOOKSHELF_SECRET_KEY") or secrets.token_hex(32)
class BookForm(FlaskForm):
title = StringField("Title", validators=[InputRequired(), Length(max=120)])
isbn = StringField(
"ISBN-13",
validators=[InputRequired(), Regexp(r"^97[89]\d{10}$", message="Enter 13 digits, no hyphens.")],
)
pages = IntegerField("Pages", validators=[InputRequired(), NumberRange(min=1, max=5000)])
summary = TextAreaField("Summary", validators=[Optional(), Length(max=2000)])
in_stock = BooleanField("In stock", default=True)
submit = SubmitField("Save")
BOOKS: list[dict] = []
Source: Flask-WTF: Creating Forms
SECRET_KEY is mandatory: the CSRF token is signed with it (or with WTF_CSRF_SECRET_KEY), and a form without a key
raises RuntimeError("A secret key is required to use CSRF.") the first time it is rendered. Never write the key in the
source; generate it with python -c "import secrets; print(secrets.token_hex())" and inject it through the environment or
the instance configuration. The random fallback above is acceptable for a single development process only, because every
Gunicorn worker would invent a different key and reject the others' tokens.
The View: validate_on_submit
@app.route("/books/new", methods=["GET", "POST"])
def new_book():
form = BookForm()
if form.validate_on_submit():
BOOKS.append({"title": form.title.data, "isbn": form.isbn.data, "pages": form.pages.data})
flash("Book saved.")
return redirect(url_for("new_book")) # Post/Redirect/Get
status = 422 if form.errors else 200
return render_template_string(PAGE, form=form), status
Source: Flask-WTF: Quickstart
FlaskForm() called without arguments does the Flask-specific work. If the current request uses POST, PUT, PATCH or
DELETE, it takes the submitted data from request.files and request.form (combined when files are present), or from the
JSON body when the request is JSON and there are no form fields. On a GET it builds an empty form. Pass formdata=None
to ignore the request entirely, or any other mapping to feed the form from somewhere else.
validate_on_submit() is is_submitted() and validate(). A form that is not submitted (a GET) is never validated, so a
first visit does not display "this field is required" everywhere. Three habits make this pattern correct:
-
Redirect after a successful POST (Post/Redirect/Get), so a browser refresh does not resubmit the form and
flash()shows its message on the next page. -
Return an error status when validation fails. Returning
422 Unprocessable Content(or400) tells HTMX, Turbo and scripts that the submission was rejected; returning200works for plain browsers but hides the failure from tooling. -
Read values from
form.<field>.data, never fromrequest.form, so the values you use are the converted and validated ones.
The page itself is rendered with Jinja. A macro keeps the markup for one field in one place:
PAGE = """
{% macro render_field(field) %}
<div class="field{% if field.errors %} has-error{% endif %}">
{{ field.label }}
{{ field(**kwargs) }}
{% for message in field.errors %}<span class="error">{{ message }}</span>{% endfor %}
</div>
{% endmacro %}
<form method="post" novalidate>
{{ form.hidden_tag() }}
{{ render_field(form.title) }}
{{ render_field(form.isbn, placeholder="9780441172719") }}
{{ render_field(form.pages, min=1) }}
{{ render_field(form.summary, rows=4) }}
{{ render_field(form.in_stock) }}
{{ form.submit() }}
</form>
"""
import re
def csrf_token_from(html: str) -> str:
return re.search(r'name="csrf_token"[^>]*value="([^"]+)"', html).group(1)
with app.test_client() as client:
page = client.get("/books/new")
token = csrf_token_from(page.get_data(as_text=True))
bad = client.post("/books/new", data={"csrf_token": token, "title": "Dune", "isbn": "123", "pages": "x"})
html = bad.get_data(as_text=True)
print(bad.status_code, "Enter 13 digits" in html, 'value="Dune"' in html)
missing = client.post("/books/new", data={"title": "Dune", "isbn": "9780441172719", "pages": "412"})
print(missing.status_code, "The CSRF token is missing." in missing.get_data(as_text=True))
good = client.post(
"/books/new",
data={"csrf_token": token, "title": "Dune", "isbn": "9780441172719", "pages": "412"},
)
print(good.status_code, good.headers["Location"], BOOKS)
Source: Flask-WTF: Quickstart
The facts the example demonstrates:
-
A field is callable.
field(placeholder="...", rows=4)renders the widget with those attributes added; a trailing underscore (class_=) orrender_kwon the field covers reserved words, anddata_foo=becomesdata-foo. -
field.errorsis a list of messages;form.errorsis a dict of field name to messages. The rejected submission is rendered with what the user typed (value="Dune"), because the field keepsraw_data. -
form.hidden_tag()renders every hidden field, including the CSRF token. Forgetting it is the most common reason for a form that "never validates". -
novalidateon the<form>turns off the browser’s own validation so that the server’s messages are the ones shown; browser validation is a convenience and never a defence, because anyone can send a request without a browser. -
Jinja autoescapes the field output: widgets return
Markup, while messages and values are escaped. Do not wrap user input in|safe.
Validators
A validator is a callable validator(form, field) that returns nothing when the value is fine and raises
wtforms.validators.ValidationError (message collected, validation continues with the next validator) or StopValidation
(message collected, the chain stops) otherwise. The built-in ones that matter:
| Validator | Behaviour and pitfalls |
|---|---|
|
Passes if the browser sent a non-empty raw value. Use it for text and numbers. It sets the |
|
Passes if the converted value is truthy. A |
|
If the input is empty it stops the chain, so later validators such as |
|
Length of the string or list; either bound may be omitted. |
|
Compares numbers and dates; either bound may be omitted. |
|
|
|
Syntax checks. |
|
For "repeat the password" fields. |
|
Allow or forbid listed values; since WTForms 3.2 they also work on multi-valued fields such as |
|
Reject a value different from the object’s (or any submitted value for |
Validators can be exercised outside a view with a test request context, which is also the quickest way to learn how a form reacts:
from werkzeug.datastructures import MultiDict
def check(data: dict, **kwargs):
with app.test_request_context("/books/new", method="POST"):
form = BookForm(formdata=MultiDict(data), meta={"csrf": False}, **kwargs)
ok = form.validate()
return ok, form.errors
print(check({"title": "Dune", "isbn": "9780441172719", "pages": "412"}))
print(check({"title": "", "isbn": "123", "pages": "0"}))
print(check({"title": "x" * 200, "isbn": "9780441172719", "pages": "abc"}))
Source: WTForms: Validators
A failed conversion (pages="abc") adds "Not a valid integer value." and, because InputRequired saw a non-empty raw
value, the chain continues: NumberRange then runs on None and adds its own message. Put Optional() first when a
field may be empty, and expect more than one message per field. Messages are the defaults of the validators and are
translated when the translation hooks described later are active; pass message= to override one.
Inline and Extra Validators
A form method called validate_<fieldname> is appended to that field’s validators, and validate() accepts per-call extra
validators. Both receive (form, field) (the inline one is bound, so it receives only field):
from wtforms.validators import ValidationError
class ReviewForm(FlaskForm):
reviewer = StringField("Your name", validators=[InputRequired(), Length(max=60)])
rating = IntegerField("Rating", validators=[InputRequired(), NumberRange(min=1, max=5)])
comment = TextAreaField("Comment", validators=[Length(max=1000)])
def validate_comment(self, field):
if self.rating.data is not None and self.rating.data <= 2 and len(field.data or "") < 20:
raise ValidationError("Please explain a low rating in at least 20 characters.")
def banned_words(form, field):
if "spoiler" in (field.data or "").lower():
raise ValidationError("No spoilers, please.")
with app.test_request_context("/", method="POST"):
data = MultiDict({"reviewer": "Ana", "rating": "1", "comment": "meh"})
form = ReviewForm(formdata=data, meta={"csrf": False})
print(form.validate(), form.errors)
form = ReviewForm(formdata=data, meta={"csrf": False})
print(form.validate(extra_validators={"comment": [banned_words]}), form.errors)
form = ReviewForm(
formdata=MultiDict({"reviewer": "Ana", "rating": "5", "comment": "A spoiler follows"}),
meta={"csrf": False},
)
print(form.validate(extra_validators={"comment": [banned_words]}), form.errors)
Source: WTForms: Forms
validate_on_submit(extra_validators=...) takes the same dictionary of field name to list of callables (supported since
Flask-WTF 1.1.0), which suits rules that depend on the request or on the database and therefore do not belong in a reusable
class. Cross-field rules (a date range, "one of these two is required") go into an inline method on the last field
involved, or into an overridden validate() that calls super().validate() first and then appends to form.form_errors.
Since WTForms 3.2 form-level errors are stored under the empty-string key of form.errors instead of None; code that
reads form.errors[None] must change.
Fields Worth Knowing
| Field | data is |
Notes |
|---|---|---|
|
|
|
|
|
Use |
|
|
Unchecked boxes send nothing, so the default must be applied by the field, not by the browser. |
|
|
|
|
the chosen value, converted by |
|
|
list |
Same options as |
|
|
Render the matching HTML5 input type. They live in |
|
|
Hidden is not trusted: the browser can change it. Never carry prices or permissions in one. |
|
nested form data, list |
Reusable sets of fields, described next. |
|
|
The Flask-WTF versions ( |
Source: WTForms: Fields
Reusable Field Sets: Inheritance, Mixins, FormField and FieldList
Fields are collected from the class and its bases, so a plain mixin class holding field declarations works without any
registration. FormField embeds a whole form as one field and FieldList repeats a field or a form a variable number of
times, which is how a book with several authors is submitted:
from wtforms import FieldList, FormField, SelectField
from wtforms.validators import DataRequired
class TimestampedMixin:
notes = TextAreaField("Internal notes", validators=[Optional(), Length(max=500)])
class AuthorForm(FlaskForm):
class Meta:
csrf = False # nested forms must not carry their own CSRF token
name = StringField("Name", validators=[InputRequired(), Length(max=80)])
born = IntegerField("Born", validators=[Optional(), NumberRange(min=1000, max=2100)])
class FullBookForm(TimestampedMixin, BookForm):
language = SelectField("Language", choices=[("en", "English"), ("es", "Spanish")])
authors = FieldList(FormField(AuthorForm), min_entries=1, max_entries=5)
with app.test_request_context("/", method="POST"):
print([f.name for f in FullBookForm(formdata=None, meta={"csrf": False})])
data = MultiDict(
{
"title": "Good Omens", "isbn": "9780060853983", "pages": "384", "language": "en",
"authors-0-name": "Terry Pratchett", "authors-0-born": "1948",
"authors-1-name": "Neil Gaiman", "authors-1-born": "x",
}
)
form = FullBookForm(formdata=data, meta={"csrf": False})
print(form.validate())
print(form.errors)
print([a.form.name.data for a in form.authors])
Source: WTForms: Field Enclosures
Pitfalls:
-
A nested form is a subform of the outer one: only the outer form carries the CSRF field, so give the inner class
csrf = Falsein itsMeta(or use plainwtforms.Form, which has no CSRF at all). -
FieldListnames its inputsauthors-0-name,authors-1-name, … and the browser (or your JavaScript) is responsible for numbering them. Entries are processed in index order, andmax_entriesis the only upper bound, so always set it; an attacker can otherwise submit thousands of entries. -
Use
prefix="a"on the form constructor when two forms share a page, so their field names do not collide.
Custom Validators, Fields and Widgets
The three extension points of WTForms are small and compose.
Custom Validators
A validator with options is a class with __init__ and __call__; one without is a function. The Bookshelf needs an
ISBN-13 check-digit validator and a "title is unique on this shelf" rule that needs state:
def isbn13(form, field):
digits = field.data or ""
if len(digits) != 13 or not digits.isdecimal():
raise ValidationError("An ISBN-13 has exactly 13 digits.")
total = sum(int(d) * (1 if i % 2 == 0 else 3) for i, d in enumerate(digits[:12]))
if (10 - total % 10) % 10 != int(digits[12]):
raise ValidationError("The ISBN check digit is wrong.")
class UniqueIn:
"""Reject a value that is already present in the collection returned by ``source()``."""
def __init__(self, source, message=None):
self.source = source
self.message = message
def __call__(self, form, field):
if field.data in {item["title"] for item in self.source()}:
raise ValidationError(self.message or "This title is already on the shelf.")
class CheckedBookForm(BookForm):
isbn = StringField("ISBN-13", validators=[InputRequired(), isbn13])
title = StringField(
"Title", validators=[InputRequired(), Length(max=120), UniqueIn(lambda: BOOKS)]
)
for isbn in ("9780441172719", "9780441172710"):
with app.test_request_context("/", method="POST"):
form = CheckedBookForm(
formdata=MultiDict({"title": "Dune", "isbn": isbn, "pages": "412"}), meta={"csrf": False}
)
print(isbn, form.validate(), form.errors)
Source: WTForms: Custom Validators
Two details decide whether a validator is correct. Raise StopValidation when an error makes the following validators
meaningless (the built-in Optional and FileRequired do), and never mutate field.data inside a validator; convert in a
filters=[...] callable or in the field instead. A validator that queries the database is subject to a race (two requests
can both pass the check): keep the uniqueness constraint in the database as well and handle the integrity error.
Custom Fields
A field converts between form input and Python. Override process_formdata(valuelist) to turn the list of submitted
strings into data, and _value() to produce the string shown in the widget:
from wtforms import Field
from wtforms.widgets import TextInput
class TagListField(Field):
"""Comma separated text in the browser, list[str] in Python."""
widget = TextInput()
def _value(self):
return ", ".join(self.data) if self.data else ""
def process_formdata(self, valuelist):
if valuelist:
tags = [t.strip().lower() for t in valuelist[0].split(",")]
self.data = sorted({t for t in tags if t})
else:
self.data = []
class TaggedBookForm(FlaskForm):
tags = TagListField("Tags", validators=[Length(max=5, message="At most five tags.")])
with app.test_request_context("/", method="POST"):
form = TaggedBookForm(formdata=MultiDict({"tags": "Sci-Fi, dune, sci-fi , classic"}), meta={"csrf": False})
print(form.validate(), form.tags.data, form.tags())
Source: WTForms: Custom Fields
The framework calls process_formdata only when the browser sent a value for the field. Raise ValueError from it to report
a conversion error; the message is added to field.errors and Length does not run on a failed conversion.
Custom Widgets
A widget is a callable widget(field, **kwargs) that returns HTML (a Markup string, so Jinja does not escape it again).
html_params builds the attribute string and escapes the values:
from markupsafe import Markup
from wtforms.widgets import html_params
class RatingWidget:
"""Render 1..max as radio buttons inside a fieldset."""
def __init__(self, max_stars: int = 5):
self.max_stars = max_stars
def __call__(self, field, **kwargs):
kwargs.setdefault("id", field.id)
parts = [f"<fieldset {html_params(id=kwargs['id'], class_='rating')}>"]
for value in range(1, self.max_stars + 1):
attrs = html_params(type="radio", name=field.name, value=value, id=f"{field.id}-{value}")
checked = " checked" if field.data == value else ""
parts.append(f'<label><input {attrs}{checked}> {value}</label>')
parts.append("</fieldset>")
return Markup("".join(parts))
class StarReviewForm(FlaskForm):
rating = IntegerField("Rating", widget=RatingWidget(), validators=[InputRequired(), NumberRange(1, 5)])
with app.test_request_context("/", method="POST"):
form = StarReviewForm(formdata=MultiDict({"rating": "4"}), meta={"csrf": False})
print(form.validate())
print(form.rating())
Source: WTForms: Widgets
Stock widgets (TextArea, Select, ListWidget, TableWidget, PasswordInput, HiddenInput, FileInput, the HTML5 input
widgets such as NumberInput(step=..., min=..., max=...)) cover most needs. Write a custom one when the markup differs
(a rating control, a date picker wrapper); do not use one to hide logic that belongs to a validator. Since WTForms 3.2 the
hidden, range and select widgets no longer emit the required attribute, to follow the W3C rules.
Choices, Dynamic Choices and QuerySelectField
SelectField takes choices at declaration time, but real option lists come from the database or depend on the user, and
the class body runs at import. Set the choices on the instance, in the view or in __init__; validation compares the
submitted value with the choices at validation time, so they must be set before validate_on_submit():
GENRES = [(1, "Science fiction"), (2, "Fantasy"), (3, "History")]
class ShelfForm(FlaskForm):
genre = SelectField("Genre", coerce=int, validators=[InputRequired()])
format = SelectField("Format", choices=[("pb", "Paperback"), ("hc", "Hardcover")])
def __init__(self, *args, genres=(), **kwargs):
super().__init__(*args, **kwargs)
self.genre.choices = [(g_id, label) for g_id, label in genres]
with app.test_request_context("/", method="POST"):
ok = ShelfForm(formdata=MultiDict({"genre": "2", "format": "pb"}), genres=GENRES, meta={"csrf": False})
bad = ShelfForm(formdata=MultiDict({"genre": "99", "format": "pb"}), genres=GENRES, meta={"csrf": False})
print(ok.validate(), ok.genre.data, bad.validate(), bad.errors)
Source: WTForms: SelectField
coerce converts the submitted string to the type of your keys (int, uuid.UUID, …); without it a numeric key never
equals the string the browser sent and "Not a valid choice" appears for a value that looks right. validate_choice=False
disables the membership test and should only be used when another validator checks the value.
The cookbook this section builds on uses QuerySelectField from wtforms.ext.sqlalchemy.fields. That package was removed
in WTForms 3 and the code moved to the separate wtforms-sqlalchemy distribution, whose module is wtforms_sqlalchemy.
The field takes a query_factory returning the objects, builds the options from their primary keys, and returns the
selected object as data:
from sqlalchemy import String, create_engine, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from wtforms_sqlalchemy.orm import model_form
class Base(DeclarativeBase):
pass
class Genre(Base):
__tablename__ = "genre"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50))
def __str__(self):
return self.name
class Book(Base):
__tablename__ = "book"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(120))
pages: Mapped[int]
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
session = Session(engine)
session.add_all([Genre(name="Science fiction"), Genre(name="Fantasy")])
session.commit()
class GenreForm(FlaskForm):
genre = QuerySelectField(
"Genre",
query_factory=lambda: session.scalars(select(Genre).order_by(Genre.name)).all(),
get_label="name",
allow_blank=True,
blank_text="-- choose --",
)
with app.test_request_context("/", method="POST"):
form = GenreForm(formdata=MultiDict({"genre": "2"}), meta={"csrf": False})
print(form.validate(), repr(form.genre.data.name))
print(form.genre())
BookModelForm = model_form(Book, db_session=session, base_class=FlaskForm, only=["title", "pages"])
with app.test_request_context("/", method="POST"):
form = BookModelForm(formdata=MultiDict({"title": "Dune", "pages": "412"}), meta={"csrf": False})
print(form.validate(), [f.name for f in form])
Source: wtforms-sqlalchemy documentation
The query factory is called on every render and validation, so it must return a fresh list (or query) every time, and the
session it uses must be the request’s session. allow_blank=True adds the empty option; the submitted value of the blank
option is __None by default. model_form generates a form class from a mapped class and is good for prototypes and admin
screens; for public forms prefer an explicit class, because a generated form exposes whichever columns it finds and
nothing reviews that list when a column is added (restrict it with only= or exclude=). With Flask-SQLAlchemy, pass
db.session; the model layer itself is covered on the Flask-SQLAlchemy page of this section.
CSRF Protection
Cross-site request forgery makes the victim’s browser send a state-changing request that the browser authenticates with its own cookies. The defence Flask-WTF implements is the synchroniser token pattern: the server stores a random value in the session and puts a signed, time-stamped version of it in every form; a forged request cannot know the token.
Forms: Automatic
Every FlaskForm has a csrf_token hidden field and validates it in validate(). Nothing else is needed beyond
form.hidden_tag() (or {{ form.csrf_token }}) in the template. The token is bound to the session cookie, signed with
WTF_CSRF_SECRET_KEY (default SECRET_KEY), and valid for WTF_CSRF_TIME_LIMIT seconds (default 3600). Because the token
depends on the session, a form left open longer than the hour, or after the session was cleared, fails with "The CSRF token
has expired." Users who keep forms open overnight need a longer limit (or None, meaning the token lives as long as the
session).
| Configuration key | Default | Effect |
|---|---|---|
|
|
Master switch for form CSRF fields and |
|
|
Key that signs the token. |
|
|
Token lifetime in seconds; |
|
|
Name of the form field and the session key. |
|
|
Headers |
|
|
Methods |
|
|
When |
|
|
On HTTPS, |
|
|
Name of the tag produced by |
|
|
Use Flask-Babel’s locale for validator messages when Flask-Babel is installed. |
Source: Flask-WTF: Configuration
Per form, WTForms' Meta class overrides the behaviour: meta={"csrf": False} in the constructor, or
class Meta: csrf = False in the class, switches the token off for that form (for example a search form submitted by GET
or a nested form). The older csrf_enabled=False constructor keyword (used by the books) no longer has any effect: FlaskForm passes unknown
keyword arguments on as object data, so the form stays protected and nothing warns you. Use meta.
CSRFProtect: Non-WTForms Views, AJAX and APIs
CSRFProtect extends the same check to every unsafe-method request of the application, whether or not a FlaskForm is
involved, by registering a before_request hook. It reads the token from the form field or from the X-CSRFToken or
X-CSRF-Token header, so JavaScript clients can send it without a form. csrf_token() is a Jinja global, and
csrf_meta_tag() (Flask-WTF 1.3) renders a <meta name="csrf-token"> tag that scripts can read:
from flask import jsonify
from flask_wtf.csrf import CSRFError, CSRFProtect, generate_csrf, validate_csrf
csrf_app = Flask("csrf_demo")
csrf_app.config["SECRET_KEY"] = os.environ.get("BOOKSHELF_SECRET_KEY") or secrets.token_hex(32)
csrf = CSRFProtect(csrf_app)
PAGE_WITH_SCRIPT = """
<!doctype html>
<head>{{ csrf_meta_tag() }}</head>
<body>
<script>
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch("/api/like", {method: "POST", headers: {"X-CSRFToken": token, "Content-Type": "application/json"},
body: JSON.stringify({book: 1})});
</script>
</body>
"""
@csrf_app.get("/")
def index():
return render_template_string(PAGE_WITH_SCRIPT)
@csrf_app.post("/api/like")
def like():
return jsonify(liked=True)
@csrf_app.post("/webhooks/printer")
@csrf.exempt # called by a server, authenticated with a signature instead of a cookie
def printer_webhook():
return jsonify(ok=True)
@csrf_app.errorhandler(CSRFError)
def csrf_failed(error):
return jsonify(error="csrf", reason=error.description), 400
with csrf_app.test_client() as client:
html = client.get("/").get_data(as_text=True)
meta_token = re.search(r'<meta name="csrf-token" content="([^"]+)"', html).group(1)
print(client.post("/api/like", json={"book": 1}).get_json())
print(client.post("/api/like", json={"book": 1}, headers={"X-CSRFToken": meta_token}).get_json())
print(client.post("/webhooks/printer", json={}).get_json())
print(client.post("/api/like", json={"book": 1}, headers={"X-CSRFToken": "forged"}).get_json())
Source: Flask-WTF: CSRF Protection
How the pieces fit:
-
Order matters. Create
CSRFProtectbefore the first request is handled (inside the application factory withcsrf.init_app(app)); Flask refuses to register hooks after that. -
Both mechanisms can coexist. When
CSRFProtecthas already validated a request it marks it ing, and the form’s own CSRF check is skipped, so the token is not verified twice. -
Exemptions.
@csrf.exempttakes a view function (decorator order matters: it goes below@route) or a blueprint. Exempt only endpoints that do not rely on cookies for authentication, such as machine-to-machine webhooks. -
Error handling. A failed check raises
CSRFError, a400 Bad Request; the default page shows the reason. Register an error handler (see Error Handling) to render something friendlier or JSON. -
Reading the token yourself.
generate_csrf()returns the signed token for the current session (the same value for the whole request) andvalidate_csrf(data)raiseswtforms.ValidationErroron failure, for protocols where the token travels in an unusual place. -
HTTPS and the referrer. With
WTF_CSRF_SSL_STRICTon, an HTTPS request without a matchingRefereris refused even with a valid token, which breaks clients that strip the header; the failure message is "The referrer header is missing." -
JSON APIs that use tokens in the
Authorizationheader are not CSRF-prone, because the browser does not attach that header on its own, so they typically exempt the API blueprint. APIs authenticated with the session cookie are prone and need the header. -
Cookies still matter. The session cookie should be
SameSite=Lax(Flask’sSESSION_COOKIE_SAMESITE, covered on the sessions page of this section) andSecurein production; SameSite reduces the attack surface but does not replace the token. -
Do not turn it off to "make it work". In tests set
WTF_CSRF_ENABLED = Falsein a testing configuration, or fetch a real token as the examples above do; never disable it in the production config.
File Fields
flask_wtf.file.FileField reads FileStorage objects from request.files (WTForms' own FileField does not, which is
why the book’s import from wtforms needs to change). Validators check presence, extension and size, and the form must be
sent as multipart/form-data:
import tempfile
from io import BytesIO
from pathlib import Path
from flask_wtf.file import FileAllowed, FileField, FileRequired, FileSize, MultipleFileField
from werkzeug.utils import secure_filename
class CoverForm(FlaskForm):
cover = FileField(
"Cover image",
validators=[
FileRequired(),
FileAllowed(["jpg", "jpeg", "png", "webp"], "Images only."),
FileSize(max_size=2 * 1024 * 1024, message="At most 2 MiB."),
],
)
extras = MultipleFileField("Extra pages", validators=[Optional(), FileAllowed(["pdf"])])
UPLOAD_DIR = Path(tempfile.mkdtemp()) # in a real app: a directory outside the web root
files_app = Flask("files")
files_app.config["SECRET_KEY"] = os.environ.get("BOOKSHELF_SECRET_KEY") or secrets.token_hex(32)
files_app.config["MAX_CONTENT_LENGTH"] = 4 * 1024 * 1024
@files_app.route("/books/<int:book_id>/cover", methods=["GET", "POST"])
def upload_cover(book_id):
form = CoverForm()
if request.method == "GET":
return form.hidden_tag()
if form.validate_on_submit():
name = f"{book_id}-{secrets.token_hex(8)}-{secure_filename(form.cover.data.filename)}"
form.cover.data.save(UPLOAD_DIR / name)
return {"stored": name}, 201
return {"errors": form.errors}, 422
with files_app.test_client() as client:
token = csrf_token_from(client.get("/books/7/cover").get_data(as_text=True))
png = (BytesIO(b"\x89PNG\r\n\x1a\n" + b"0" * 64), "../../evil name.png")
ok = client.post("/books/7/cover", data={"csrf_token": token, "cover": png}, content_type="multipart/form-data")
print(ok.status_code, ok.get_json()["stored"].endswith("-evil_name.png"), len(list(UPLOAD_DIR.iterdir())))
exe = (BytesIO(b"MZ"), "setup.exe")
bad = client.post("/books/7/cover", data={"csrf_token": token, "cover": exe}, content_type="multipart/form-data")
print(bad.status_code, bad.get_json())
none = client.post("/books/7/cover", data={"csrf_token": token}, content_type="multipart/form-data")
print(none.status_code, none.get_json())
Source: Flask-WTF: File Uploads
-
FileRequiredfails when the browser sent an empty file input, whichDataRequiredorInputRequiredcannot detect. -
FileAllowedcompares the lower-cased filename suffix against a list of extensions (or a Flask-UploadsUploadSet). The filename is chosen by the client, so this is not a content check; verify the content (for images, open the file with an imaging library) before trusting it. -
FileSize(max_size, min_size=0)is in bytes and validates one or several files (since Flask-WTF 1.2.0 the three file validators also acceptMultipleFileField). Combine it withMAX_CONTENT_LENGTH, the request-level limit that rejects oversized bodies with413before the form is built (see Request Data). -
Always pass the client’s name through
secure_filename()and add a random component, as above, or store under an identifier of your own; never use the raw name as a path.secure_filenamelives inwerkzeug.utils; the oldfrom werkzeug import secure_filenamein the books no longer works. -
Storing the file, serving it with
send_from_directory, size limits and image processing are the subject of the File Uploads page of this section.
The reCAPTCHA Field
Flask-WTF ships RecaptchaField, which renders Google reCAPTCHA v2 (or a compatible service) and verifies the response with
the provider’s server-side API. It is mentioned here for completeness rather than recommended: bots increasingly solve
challenges, and a rate limiter, honeypot field or a provider with a privacy-respecting challenge is often a better answer.
The keys come from configuration, never from source:
from flask_wtf import RecaptchaField
app.config["RECAPTCHA_PUBLIC_KEY"] = os.environ.get("RECAPTCHA_PUBLIC_KEY", "")
app.config["RECAPTCHA_PRIVATE_KEY"] = os.environ.get("RECAPTCHA_PRIVATE_KEY", "")
class ContactForm(FlaskForm):
name = StringField("Name", validators=[InputRequired()])
captcha = RecaptchaField()
# The validator is skipped when app.testing is true or RECAPTCHA_ENABLED is false.
app.config["TESTING"] = True
with app.test_request_context("/", method="POST"):
form = ContactForm(formdata=MultiDict({"name": "Ana"}), meta={"csrf": False})
print(form.validate())
app.config["TESTING"] = False
Source: Flask-WTF: reCAPTCHA
RECAPTCHA_PUBLIC_KEY and RECAPTCHA_PRIVATE_KEY are required (the widget and the verification raise RuntimeError when
they are missing); RECAPTCHA_PARAMETERS, RECAPTCHA_DATA_ATTRS, RECAPTCHA_SCRIPT, RECAPTCHA_DIV_CLASS,
RECAPTCHA_HTML and RECAPTCHA_VERIFY_SERVER customise the script, the markup and the verification endpoint (which is
how hCaptcha or Turnstile are plugged in, since Flask-WTF 1.0). Version 1.3 added a nonce argument for pages with a
nonce-based Content Security Policy. The page loads a third-party script and sends the visitor’s data to the provider, so
account for it in your privacy notice and your CSP.
Translating Validation Messages
WTForms ships translated validator messages. With Flask-Babel installed and WTF_I18N_ENABLED true (the default),
Flask-WTF uses the locale that Flask-Babel selected for the request. Without Flask-Babel, or with the switch off, the
Meta.locales list picks the language, which is also the easiest way to test it:
app.config["WTF_I18N_ENABLED"] = False
class SpanishBookForm(BookForm):
class Meta(BookForm.Meta):
locales = ["es"]
with app.test_request_context("/", method="POST"):
form = SpanishBookForm(formdata=MultiDict({"title": "", "isbn": "x", "pages": "1"}), meta={"csrf": False})
form.validate()
print(form.errors["title"])
app.config["WTF_I18N_ENABLED"] = True
Source: WTForms: Internationalization
Labels and your own messages are not translated automatically; wrap them in lazy_gettext (see the internationalisation
page of this section). Message strings in message= arguments are shown verbatim.
Testing Forms
Two approaches work, and both avoid weakening production configuration:
-
Unit-test the form in a
test_request_contextwithmeta={"csrf": False}, as the examples above do. It tests validators and conversions without HTTP. -
Test the view with the test client. Either configure
WTF_CSRF_ENABLED = Falsein the testing config, which skips every token check, or do the real round trip (GET the page, extract the token with a regular expression or an HTML parser, POST it back) to prove that the template really containshidden_tag(). Keep at least one test of the second kind per form, since the first can hide a missing token.
Test the failure paths too: a missing token, a tampered token and an expired token (set WTF_CSRF_TIME_LIMIT to -1
inside the test to expire immediately).
Pitfalls and Security Summary
-
Missing
hidden_tag()(orform.csrf_token) makes every POST fail with "The CSRF token is missing." -
validate()returnsFalsefor unsubmitted forms. Usevalidate_on_submit()in views; aGETform never validates. -
Choices must be set before validation, and
coercemust match the type of the choice values. -
DataRequiredrejects0andFalse. PreferInputRequiredfor numbers and useOptionaldeliberately. -
Never trust
HiddenField,readonlyordisabled. They are rendering hints; validate on the server. -
Allow-list, do not deny-list, for uploads, redirect targets (
nextparameters) and sort columns that come from forms. -
Mass assignment. Do not copy
form.datastraight onto a model (Book(**form.data)); it includes the CSRF token and the submit button, and lets a future field reach an attribute you did not intend. Assign fields explicitly or withform.populate_obj(book)on a form that holds only the editable fields. -
Re-displaying input is safe only because of autoescaping. Do not build field HTML with string formatting and
|safe. -
A
SECRET_KEYthat changes between restarts invalidates every open form and session; keep it stable and rotate it deliberately. -
Cookies and CORS. CSRF protection assumes the browser sends cookies only from your own site; a permissive CORS policy with credentials defeats it (see CORS).
Recent Changes and Where the Books Are Outdated
| Version | Change | Consequence |
|---|---|---|
Flask-WTF 1.3.0 (2026-04-23) |
|
Documented only in the package at the time of writing, because the documentation site still shows 1.2.x. |
Flask-WTF 1.2.0 (2023-10-01) |
|
Use it instead of a loop over |
Flask-WTF 1.1.0 (2023-01-15) |
|
Use Flask-Babel. |
Flask-WTF 1.0.0 (2021-11-07) |
Deprecated items removed; alternative CAPTCHA services supported. |
|
WTForms 3.2.0 (2024-10-20) |
|
Code reading |
WTForms 3.0 |
|
Use |
Source: Flask-WTF: Changes
The cookbook chapter on forms is useful for its structure (a form per concern, shared field sets, a custom field, a custom widget, a file upload and CSRF with AJAX) and every one of those ideas is covered above, but its code predates the changes in the table:
-
from flask_wtf import FormandFormas the base class:flask_wtf.Formis now only a re-export of WTForms' plainForm, which has no request integration and no CSRF field. UseFlaskForm. -
from wtforms import TextFieldraisesImportError; useStringField.validators.Requiredis likewise gone. -
from wtforms.ext.sqlalchemy.fields import QuerySelectFieldfails becausewtforms.extno longer exists; import fromwtforms_sqlalchemy.fields. -
FileFieldmust come fromflask_wtf.file, andsecure_filenamefromwerkzeug.utils. -
Form(csrf_enabled=False)was replaced bymeta={"csrf": False}; the old keyword is now silently ignored. -
Queries written as
Model.query.all()in thequery_factoryare the legacy SQLAlchemy API; usesession.scalars(select(Model))ordb.session.scalars(db.select(Model)). -
Examples with a literal
SECRET_KEYor a key committed to the repository must be replaced by a generated key held in the environment.
Summary
-
A
FlaskFormdeclares fields and validators, readsrequest.formandrequest.filesitself, and signs a CSRF token. -
In a view: build the form,
validate_on_submit(), act and redirect; otherwise render with the errors and a 422. -
Put the token in the page with
form.hidden_tag(); for AJAX and non-WTForms views useCSRFProtectand theX-CSRFTokenheader (orcsrf_meta_tag()). -
Custom validators are callables, custom fields override
process_formdataand_value, custom widgets returnMarkup. -
Set
choiceson the instance before validating; usewtforms-sqlalchemyfor query-backed selects. -
File fields come from
flask_wtf.file, and the extension check is not a content check.