Form Handling — Flask-WTF

In this chapter, you will learn to use Flask-WTF to standardize form handling: defining form classes, automatic CSRF protection, field validation, and error messages.


Why use Flask-WTF?

The registration and login forms in the previous chapter were handwritten: manually fromrequest.form.get()Getting values, manual validation, manually displaying errors.

Flask-WTFA wrapper around WTForms for Flask, providing:

  • Form class definition, centralized management of fields + validation rules.
  • Automatic CSRF Protection
  • Rendering Fields and Error Messages in Templates
  • form.validate_on_submit()Unified handling of GET/POST determination and validation.

Installation and Configuration

(venv) $ pip install flask-wtf

Configure SECRET_KEY in app.py (CSRF Token depends on this key):

Example

# File path: app.py
app.config['SECRET_KEY'] = 'your-secret-key'    # Use environment variables for production environment
app.config['WTF_CSRF_ENABLED'] = True            # Enabled by default, explicitly declare it

Define form class

Example

# File path: app/forms.py (new file)
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, EmailField, BooleanField
from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError
from app.models import User

class RegisterForm(FlaskForm):
    Registration Form
    username = StringField(Username, validators=[
        DataRequired(message=Username cannot be empty),
        Length(min=3, max=50, message=Username must be 3-50 characters)
    ])
    email = EmailField(Email, validators=[
        DataRequired(message=Email cannot be empty),
        Email(message=Please enter a valid email address)
    ])
    password = PasswordField(Password, validators=[
        DataRequired(message=Password cannot be empty),
        Length(min=6, message=Password must be at least 6 characters)
    ])
    confirm_password = PasswordField(Confirm password, validators=[
        DataRequired(message=Please enter the password again),
        EqualTo('password', message=The two passwords do not match)
    ])

    # Custom validator: check whether the username is already registered
    def validate_username(self, field):
        if User.query.filter_by(username=field.data).first():
            raise ValidationError(This username is already taken.)

    def validate_email(self, field):
        if User.query.filter_by(email=field.data).first():
            raise ValidationError(This email is already registered.)


class LoginForm(FlaskForm):
    """Login form"""
    username = StringField(Username, validators=[
        DataRequired(message=Username cannot be empty)
    ])
    password = PasswordField(Password, validators=[
        DataRequired(message=Password cannot be empty)
    ])
    remember = BooleanField(Remember me)

Common field types

Field classHTML tagDescription
StringField<input type="text">Single-line text
PasswordField<input type="password">Password input
EmailField<input type="email">Email input
TextAreaField<textarea>Multi-line text
BooleanField<input type="checkbox">Checkbox
SelectField<select>Dropdown selection

Common validators

ValidatorFunction
DataRequired()Field cannot be empty
Length(min=, max=)Character length limit
Email()Email format validation
EqualTo('field_name')Matches the value of another field
Optional()Allow field to be empty (skip subsequent validators).

Using Forms in View Functions

Example

# File path: app/blueprints/auth.py (refactored register and login)
from flask import Blueprint, render_template, redirect, url_for, flash
from flask_login import login_user, logout_user, login_required, current_user
from app.forms import RegisterForm, LoginForm
from app.models import User, db

auth_bp = Blueprint('auth', __name__)

@auth_bp.route("/register", methods=['GET', 'POST'])
def register():
    if current_user.is_authenticated:
        return redirect(url_for('main.index'))

    form = RegisterForm()
    # form.validate_on_submit() validates the form on POST requests
    # Returns False on GET requests or when validation fails
    if form.validate_on_submit():
        user = User(username=form.username.data, email=form.email.data)
        user.set_password(form.password.data)
        db.session.add(user)
        db.session.commit()
        login_user(user)
        flash(fRegistration successful, welcome, {user.username}!, 'success')
        return redirect(url_for('main.index'))

    # On GET requests or validation failure, render the template with the form
    return render_template('register.html', form=form)


@auth_bp.route("/login", methods=['GET', 'POST'])
def login():
    if current_user.is_authenticated:
        return redirect(url_for('main.index'))

    form = LoginForm()
    if form.validate_on_submit():
        user = User.query.filter_by(username=form.username.data).first()
        if user is None or not user.check_password(form.password.data):
            flash(Incorrect username or password., 'error')
            return render_template('login.html', form=form)

        login_user(user, remember=form.remember.data)
        flash(fWelcome back, {user.username}!, 'success')
        next_page = request.args.get('next')
        return redirect(next_page or url_for('main.index'))

    return render_template('login.html', form=form)

Rendering forms in templates

Example

<!-- File path: app/templates/register.html -->
{% extends 'base.html' %}

{% block title %}Register - EXAMPLE Blog{% endblock %}

{% block content %}
<div class="auth-form">
    <h2>Register</h2>
    <form method="post">
        <!-- form.hidden_tag() automatically generates the CSRF Token hidden field -->
        {{ form.hidden_tag() }}

        <div class="form-group">
            {{ form.username.label }}
            {{ form.username(class="form-input") }}
            {% if form.username.errors %}
                {% for error in form.username.errors %}
                <span class="field-error">{{ error }}</span>
                {% endfor %}
            {% endif %}
        </div>

        <div class="form-group">
            {{ form.email.label }}
            {{ form.email(class="form-input") }}
            {% for error in form.email.errors %}
            <span class="field-error">{{ error }}</span>
            {% endfor %}
        </div>

        <div class="form-group">
            {{ form.password.label }}
            {{ form.password(class="form-input") }}
            {% for error in form.password.errors %}
            <span class="field-error">{{ error }}</span>
            {% endfor %}
        </div>

        <div class="form-group">
            {{ form.confirm_password.label }}
            {{ form.confirm_password(class="form-input") }}
            {% for error in form.confirm_password.errors %}
            <span class="field-error">{{ error }}</span>
            {% endfor %}
        </div>

        {{ form.submit(class="btn-submit") }}
    </form>
    <p class="form-footer">
Already have an account?<a href="{{ url_for('auth.login') }}">Login now</a>
    </p>
</div>
{% endblock %}

Flask-WTF's CSRF protection is automatic:form.hidden_tag()Will generate a hidden<input>Contains a random token; the server validates this token before accepting the POST request. Any form submitted from an external site is rejected because it lacks the correct token.


Manual request.form vs Flask-WTF comparison

AspectManual request.formFlask-WTF
Lines of codeFor each field, repeat the three steps: retrieve/validate/reportDefine form class once
CSRF protectionManual implementation requiredAutomatic
Custom validationManual if/elsevalidate_xxx method
Error displayManual concatenationform.field.errors auto-collection
Applicable scenariosSimple forms (e.g., search box)Complex Forms (Registration, Login, Profile Editing)

Chapter summary

In this chapter, you refactored the authentication form using Flask-WTF: FlaskForm defines fields and validators, validate_xxx custom validation, form.validate_on_submit() handles POST validation uniformly, form.hidden_tag() provides automatic CSRF protection, and error messages are rendered in templates.

Form handling is now more standardized and secure.

other extensions