Form Validation & CSRF
The previous lesson introduced AddBookForm with a single validator,
DataRequired(). That's only a taste of what WTForms validators can do, and there's
a second, entirely separate concern bound up with every Flask-WTF form: CSRF protection, which
Flask-WTF quietly handles for you the moment you configure a SECRET_KEY. This
lesson covers both — declaring richer validation rules, and understanding the security
mechanism that form.hidden_tag() was actually rendering all along.
WTForms Validators: A Reference
A validator is a small, reusable rule object passed in a field's validators list.
WTForms runs every validator for a field, in order, whenever form.validate_on_submit()
(or form.validate()) is called, collecting any failures as error messages attached
to that field.
| Validator | What it checks |
|---|---|
DataRequired() | The field wasn't left empty |
Length(min=1, max=200) | A string field's length falls within the given bounds |
NumberRange(min=1) | A numeric field falls within the given bounds (here, at least 1) |
Email() | A string field looks like a valid email address |
Optional() | Marks a field as not required — skips the remaining validators entirely if the field was left empty |
Applied to Bookshelf's AddBookForm, a title should be both present and reasonably
sized — nobody's book title is 500 characters long, and an empty title is meaningless:
# forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, IntegerField, SubmitField
from wtforms.validators import DataRequired, Length, NumberRange
class AddBookForm(FlaskForm):
title = StringField(
'Title',
validators=[DataRequired(), Length(min=1, max=200)],
)
pages = IntegerField(
'Pages',
validators=[DataRequired(), NumberRange(min=1)],
)
submit = SubmitField('Add Book')
Both validators on title run when the form is submitted — an empty title trips
DataRequired(), and a 500-character title trips Length, each
producing its own error message on the field.
Custom Validation with validate_<fieldname>
NumberRange(min=1) stops a negative or zero page count, but it can't express
something more specific to Bookshelf's domain — say, rejecting an obviously wrong value like
a 50,000-page book (nobody's reading that). WTForms lets a form class define its own
validation logic for a specific field by adding a method named
validate_<fieldname>; WTForms discovers and calls it automatically as part
of the normal validation pass, no registration needed.
# forms.py
from wtforms import ValidationError
class AddBookForm(FlaskForm):
title = StringField('Title', validators=[DataRequired(), Length(min=1, max=200)])
pages = IntegerField('Pages', validators=[DataRequired(), NumberRange(min=1)])
submit = SubmitField('Add Book')
def validate_pages(self, field):
if field.data > 20000:
raise ValidationError('That page count looks too high — double-check it.')
field.data here is the already-converted, already-passed-NumberRange
value for pages. Raising ValidationError with a message inside this
method attaches that message to the pages field's error list, exactly as if a
built-in validator had failed — the rest of the form's validation and error-display machinery
doesn't need to know the difference.
Displaying Errors in a Template
When validation fails, each field's collected error messages are available as a list on
form.<fieldname>.errors. Looping over that list next to the field is the
standard pattern:
<!-- templates/add_book.html -->
{% extends "base.html" %}
{% block content %}
<form method="POST">
{{ form.hidden_tag() }}
{{ form.title.label }} {{ form.title() }}
{% for error in form.title.errors %}
<span class="field-error">{{ error }}</span>
{% endfor %}
{{ form.pages.label }} {{ form.pages() }}
{% for error in form.pages.errors %}
<span class="field-error">{{ error }}</span>
{% endfor %}
{{ form.submit() }}
</form>
{% endblock %}
Because form.title.errors is simply an empty list when validation passed (or
hasn't run yet, on the first GET), this loop safely renders nothing at all in the
common case — the error markup only ever appears when there's something to say.
CSRF: The Attack It Prevents
Picture this scenario: Sonu is logged into Bookshelf in one browser tab. In another tab, he
visits some unrelated, malicious website. That malicious page contains a hidden auto-submitting
form that points at https://bookshelf.example.com/books/1/delete. Because Sonu's
browser automatically attaches his Bookshelf session cookie to any request sent to
bookshelf.example.com — regardless of which tab or page triggered it — that
request would arrive at Bookshelf looking exactly like a legitimate, logged-in delete request
from Sonu himself. This is Cross-Site Request Forgery (CSRF): a malicious site
tricking a victim's own browser into submitting an unwanted, authenticated request to a site
the victim happens to be logged into.
How Flask-WTF Stops It — Automatically
Flask-WTF's fix is a per-session, cryptographically signed token embedded in every form it
renders. That's precisely what {{ form.hidden_tag() }} was doing back in the
previous lesson — rendering a hidden input carrying this CSRF token — and it happens for
every FlaskForm without you writing a single line of extra code, as long
as the app has a SECRET_KEY configured:
# app.py
app.config['SECRET_KEY'] = 'a-long-random-value-kept-out-of-source-control'
Flask uses that key to sign the token; on submission, Flask-WTF checks the token against the
visitor's session and rejects the request if it's missing or doesn't match. See
Configuration & Environments for the full picture
on managing a SECRET_KEY safely across development and production. The malicious
page from the scenario above has no way to read or forge this token — it doesn't know the
secret key, and the token itself is tied to Sonu's own session — so its forged delete request
gets rejected before it ever reaches Bookshelf's view logic.
What Happens on a Missing or Invalid Token
If a submitted form arrives without a valid CSRF token — because it's missing, expired, or
doesn't match — Flask-WTF responds with an HTTP 400 Bad Request before your
view function's logic even runs. This is not a bug to work around; it's the protection doing
exactly its job. If you ever see this in your own Bookshelf development and you're sure the
request is legitimate, the fix is almost always that {{ form.hidden_tag() }} was
left out of the template, or the form is submitting to a session that's already expired — not
that CSRF protection itself needs disabling.
CSRF Protection Outside a FlaskForm
Not every form in a real app is built from a FlaskForm class — sometimes you need
a quick, one-off HTML form with no fields worth declaring in Python. Flask-WTF's CSRF
protection still covers these, via a template-global function,
{{ csrf_token() }}, that you place directly inside a plain
<form>:
<form method="POST" action="/books/1/delete">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit">Delete</button>
</form>
This renders the same protection as form.hidden_tag(), just without needing a
full form class behind it — useful for simple action forms like a delete button that only
ever submits itself, with no fields to validate.
AddBookForm and covers everything that changes when a form needs to accept a file instead of just text.