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.

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

Note: This validation code is correct, real Flask-WTF/WTForms usage — but this website's own project doesn't run a Flask server, so no form on this page actually submits or shows a live error.

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.

Coming up next: File Uploads adds a cover-image field to AddBookForm and covers everything that changes when a form needs to accept a file instead of just text.