In this chapter, you will learn how to use Flask-WTF to handle forms in a standardized way: defining form classes, automatic CSRF protection, field validation, and error display.
Why Use Flask-WTF?
In the previous chapter, the registration and login forms were handwritten: manually retrieving values from request.form.get(), manually validating, and manually displaying errors.
Flask-WTF is built on WTForms and packaged for Flask, providing:
- Form class definitions with centralized management of fields and validation rules
- Automatic CSRF protection
- Rendering fields and error messages in templates
form.validate_on_submit()to uniformly handle GET/POST logic and validation
Installation and Configuration
(venv) $ pip install flask-wtf
Configure SECRET_KEY in app.py (CSRF Token depends on this secret key):
Example
# File path: app.py app.config['SECRET_KEY']='your-secret-key'# Use environment variables in production app.config['WTF_CSRF_ENABLED']=True# Enabled by default, explicitly declared here
Defining Form Classes
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 is required'),
Length(min=3,max=50, message='Username must be 3-50 characters')
])
email= EmailField('Email', validators=[
DataRequired(message='Email is required'),
Email(message='Please enter a valid email address')
])
password = PasswordField('Password', validators=[
DataRequired(message='Password is required'),
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='Passwords do not match')
])
# Custom validator: check if 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 is required')
])
password = PasswordField('Password', validators=[
DataRequired(message='Password is required')
])
remember = BooleanField('Remember me')
Common Field Types
| Field Class | HTML Tag | Description |
|---|---|---|
| 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 select |
Common Validators
| Validator | Function |
|---|---|
| DataRequired() | Field cannot be empty |
| Length(min=, max=) | Character length limit |
| Email() | Email format validation |
| EqualTo('field_name') | Must match another field's value |
| Optional() | Allows field to be empty (skips 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(f'Registration successful, welcome {user.username}!','success')
return redirect(url_for('main.index'))
# On GET or failed validation, render template with 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('Invalid username or password.','error')
return render_template('login.html', form=form)
login_user(user, remember=form.remember.data)
flash(f'Welcome 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 - TUTORIAL Blog{% endblock %}
{% block content %}
<div class="auth-form">
<h2>Register</h2>
<form method="post">
<!-- form.hidden_tag() automatically generates 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') }}">Log in now</a>
</p>
</div>
{% endblock %}
Flask-WTF's CSRF protection is automatic:
form.hidden_tag()generates a hidden<input>containing a random Token, which the server validates before accepting POST requests. Any form submitted from an external site will be rejected because it lacks the correct Token.
Manual request.form vs Flask-WTF Comparison
| Aspect | Manual request.form | Flask-WTF |
|---|---|---|
| Lines of code | Repetitive get/validate/report steps for each field | Define form class once |
| CSRF protection | Must implement manually | Automatic |
| Custom validation | Manual if/else | validate_xxx methods |
| Error display | Manual concatenation | form.field.errors automatically collected |
| Applicable scenarios | Simple forms (e.g., search box) | Complex forms (registration, login, profile editing) |
Chapter Summary
In this chapter, you refactored the authentication forms with Flask-WTF: FlaskForm for defining fields and validators, validate_xxx for custom validation, form.validate_on_submit() for unified POST validation, form.hidden_tag() for automatic CSRF protection, and rendering error messages in templates.
Form handling is now more standardized and secure.
YouTip