📂 Stage: Stage 2 - Interaction and Data (Core)
🔗 Related chapters: 数据验证 · Jinja2 模板引擎(下)
1. Why do you need Flask-WTF?
If you only use the one that comes with Flaskrequest.formWhen dealing with forms, your code can quickly become bloated and brittle. You must be familiar with the following way of writing:
# ❌ 原始表单:手动取数据、零验证、零防护
@app.route("/register", methods=["GET", "POST"])
def register():
if request.method == "POST":
name = request.form.get("name")
email = request.form.get("email")
# 毫无验证 —— 空字段、格式错误、超长字符串统统接收入库
# 全部是字符串 —— 需要手动转换类型
# 不存在 CSRF 防护 —— 攻击者可以轻易伪造请求
This "streaking" method not only makes the routing function longer and longer, but also brings three major hidden dangers: dirty and messy data, scattered business logic, and full of security holes.
The emergence of Flask-WTF is precisely to standardize and automate these trivial tasks.
1.2 Core capabilities of Flask-WTF
Flask-WTF is a Flask-specific package for WTForms. It can be used out of the box and mainly helps you with:
- Form class definition - Use Python classes to describe form fields, with clear structure and reusability
- Automatic CSRF Protection - For POST / PUT / DELETE requests that modify data, no additional code is needed to prevent cross-site request forgery
- Built-in and custom validators - Required fields, email formats, length limits, etc. can be directly adjusted to the database, and business-level verification rules can be easily written (such as "Is this email address registered?")
- In-depth integration with Jinja2 - Rendering forms, displaying errors, and backfilling data are all automated
- Field-level error feedback - only prompts which field has an error, and will not let the user fill in the entire form again.
2. Quick installation and basic configuration
2.1 Installation
In addition to Flask-WTF itself, it also requiresemail-validatorTo support email verification fields:
pip install flask-wtf email-validator
SECRET_KEYIt is the "seed key" for generating CSRF Token, which must be set and cannot be leaked. When using factory functions, they are usually instantiated individuallyCSRFProtect, and then initialized with the application:
# app/__init__.py
from flask import Flask
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect()
def create_app():
app = Flask(__name__)
# 从环境变量获取密钥,开发阶段可写临时值
app.config["SECRET_KEY"] = "dev-mode-only-secret-key-12345"
csrf.init_app(app)
return app
⚠️ Production Environment:SECRET_KEYMust be written asos.environ.get("SECRET_KEY")or other strong random strings, the plaintext key must not be submitted to the repository.
Form class inheritanceFlaskForm, the fields come from components provided by WTForms, and the validation rules passvalidatorsList appended. The first parameter for each field is the label text for that field.
# app/forms/auth.py
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo, ValidationError
from app.models import User # 假设已经有了用户模型
class RegisterForm(FlaskForm):
name = StringField("用户名", validators=[
DataRequired(message="用户名不能为空"),
Length(min=2, max=20, message="长度需在 2-20 个字符之间"),
])
email = StringField("邮箱", validators=[
DataRequired(message="请填写邮箱"),
Email(message="邮箱格式不正确"),
])
password = PasswordField("密码", validators=[
DataRequired(message="请输入密码"),
Length(min=8, message="密码至少需要 8 位"),
])
password_confirm = PasswordField("确认密码", validators=[
DataRequired(message="请再次输入密码"),
EqualTo("password", message="两次密码不一致"),
])
agree_terms = BooleanField("我已阅读并同意《用户协议》", validators=[
DataRequired(message="必须勾选协议才能注册"),
])
submit = SubmitField("立即注册")
# 自定义验证器:方法名必须是 validate_<field_name>
def validate_email(self, field):
# field.data 是用户提交的实际值
if User.query.filter_by(email=field.data).first():
raise ValidationError("这个邮箱已经被注册了,试试登录或换一个吧")
3.2 Article publishing form (including dynamic drop-down options)
When a field's options are derived from a database (such as article classification), they need to be loaded dynamically when the form is instantiated. by rewriting__init__accomplish:
# app/forms/content.py
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SelectField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length
class ArticleForm(FlaskForm):
title = StringField("文章标题", validators=[
DataRequired(message="标题不能为空"),
Length(max=200, message="标题不能超过 200 字"),
])
summary = StringField("文章摘要", description="简要介绍,会展示在列表中(可选)", validators=[
Length(max=300, message="摘要不能超过 300 字"),
])
content = TextAreaField("正文", validators=[
DataRequired(message="正文不能空"),
Length(min=10, message="正文至少 10 个字"),
])
# coerce=int 会把提交的字符串自动转成整数,方便和数据库主键匹配
category = SelectField("文章分类", coerce=int, validators=[
DataRequired(message="请选择一个分类"),
])
tags = StringField("文章标签", description="多个标签用英文逗号分隔")
is_published = BooleanField("立即发布")
submit = SubmitField("保存草稿")
publish_now = SubmitField("立即发布") # 第二个提交按钮,允许多流程
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 延迟导入,避免循环依赖
from app.models import Category
# choices 需要 (value, label) 元组列表
self.category.choices = [(c.id, c.name) for c in Category.query.all()]
🧠 The two submit buttons correspond to different business operations (save draft vs publish immediately), which will be passed later in routingform.publish_now.dataTrue and false values to determine which one the user clicked.
4.1 Classic registration/login process
The core method of Flask-WTF is validate_on_submit():
- When the request method is
POSTand when all fields of the form are verified, returnTrue;
- Returned in other cases (GET request or verification failure)
False。
# app/routes/auth.py
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from app.forms.auth import LoginForm, RegisterForm
from app.models import User
from app.extensions import 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()
if form.validate_on_submit():
# 拿到的是已验证、已清洗的数据
user = User(
name=form.name.data,
email=form.email.data,
password_hash=generate_password_hash(form.password.data),
)
db.session.add(user)
db.session.commit()
flash("注册成功!现在可以登录了", "success")
return redirect(url_for("auth.login"))
# GET 或验证失败会走到这里,form 对象会自动携带错误信息
return render_template("auth/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(email=form.email.data).first()
if user and check_password_hash(user.password_hash, form.password.data):
login_user(user, remember=form.remember_me.data)
flash("登录成功!", "success")
next_page = request.args.get("next")
return redirect(next_page or url_for("main.index"))
else:
flash("邮箱或密码不正确", "danger")
return render_template("auth/login.html", form=form)
For the article form above, just check the corresponding button after the verification is passed..dataproperty:
# 在创建文章的路由中
if form.validate_on_submit():
article = Article(
title=form.title.data,
summary=form.summary.data,
content=form.content.data,
category_id=form.category.data,
tags=form.tags.data,
author_id=current_user.id,
)
if form.publish_now.data: # 用户点击了"立即发布"
article.is_published = True
flash("文章发布成功!", "success")
else: # 用户点击了"保存草稿"
article.is_published = False
flash("文章已保存为草稿", "info")
db.session.add(article)
db.session.commit()
return redirect(url_for("content.my_articles"))
5.1 Basic manual rendering
Although manual rendering requires a little more code, it allows you to fully customize the HTML structure of every part. Key Points:form.hidden_tag()Must be rendered, its content is the hidden CSRF Token field.
<!-- templates/auth/register.html -->
{% extends "base.html" %}
{% block content %}
<div class="container auth-container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<h2 class="text-center mb-4">用户注册</h2>
<!-- 显示闪现消息 -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
{{ form.hidden_tag() }}
<!-- 用户名字段 -->
<div class="mb-3">
{{ form.name.label(class="form-label") }}
{{ form.name(class="form-control" + (" is-invalid" if form.name.errors else "")) }}
{% if form.name.errors %}
<div class="invalid-feedback d-block">{{ form.name.errors[0] }}</div>
{% endif %}
</div>
<!-- 邮箱字段 -->
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(
class="form-control" + (" is-invalid" if form.email.errors else ""),
placeholder="your@email.com"
) }}
{% if form.email.errors %}
<div class="invalid-feedback d-block">{{ form.email.errors[0] }}</div>
{% endif %}
</div>
<!-- 勾选框需要特殊处理:让 input 在前,label 在后 -->
<div class="mb-3 form-check">
{{ form.agree_terms(class="form-check-input" + (" is-invalid" if form.agree_terms.errors else "")) }}
{{ form.agree_terms.label(class="form-check-label") }}
{% if form.agree_terms.errors %}
<div class="invalid-feedback d-block">{{ form.agree_terms.errors[0] }}</div>
{% endif %}
</div>
{{ form.submit(class="btn btn-primary w-100") }}
</form>
</div>
</div>
</div>
{% endblock %}
When there are more and more forms in the project, the repeated rendering logic can be extracted into a macro to achieve one function to render all field types.
<!-- templates/macros/form_macros.html -->
{% macro render_bs5_field(field, **kwargs) %}
<div class="mb-3{% if field.errors %} has-error{% endif %}">
{% if field.type == 'BooleanField' %}
<!-- 布尔字段:input + label 用 form-check 结构 -->
<div class="form-check">
{{ field(class="form-check-input" + (" is-invalid" if field.errors else ""), **kwargs) }}
{{ field.label(class="form-check-label") }}
</div>
{% else %}
<!-- 普通字段:label 在上,input 在下 -->
{{ field.label(class="form-label") }}
{{ field(class="form-control" + (" is-invalid" if field.errors else ""), **kwargs) }}
{% endif %}
{% if field.description %}
<small class="form-text text-muted">{{ field.description }}</small>
{% endif %}
{% if field.errors %}
<div class="invalid-feedback d-block">{{ field.errors[0] }}</div>
{% endif %}
</div>
{% endmacro %}
Import and use in any template:
<!-- templates/content/create_article.html -->
{% extends "base.html" %}
{% from "macros/form_macros.html" import render_bs5_field %}
{% block content %}
<div class="container mt-5">
<h2 class="mb-4">发布文章</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
{{ form.hidden_tag() }}
{{ render_bs5_field(form.title) }}
{{ render_bs5_field(form.summary) }}
{{ render_bs5_field(form.content, rows=15) }}
{{ render_bs5_field(form.category) }}
{{ render_bs5_field(form.tags) }}
{{ render_bs5_field(form.is_published) }}
<div class="d-flex gap-2">
{{ form.submit(class="btn btn-secondary") }}
{{ form.publish_now(class="btn btn-primary") }}
</div>
</form>
</div>
{% endblock %}
6. Key details of CSRF protection
6.1 Brief description of attack principle
Cross-site request forgery (CSRF) can be understood like this:
The attacker buries an invisible form on his website that points to a sensitive interface of your site (such as changing a password). Once a user who has logged in to your website visits the attacker's page, the browser will automatically bring your website cookie to submit the form, performing malicious operations without anyone noticing.
Because the browser automatically attaches the cookie of the target domain when sending a request, attackers take advantage of this and use the user's hands to complete unauthorized operations.
6.2 Flask-WTF protection mechanism
Flask-WTF generates a random, one-time CSRF Token for each form. The specific process is:
- Generate and save: When rendering the form, the server creates a random string (Token) and stores it in the user Session;
- Inject form: Pass
{{ form.hidden_tag() }}, the Token will be put into the hidden field and sent to the browser along with the form;
- Comparison verification: When the user submits the form, the server takes out the Token from the Session and the request body respectively. The two must be completely consistent; any inconsistency will cause the request to be rejected;
- Why the attacker cannot get the Token: Due to the restrictions of the same-origin policy, the attacker cannot read cookies or page content in other domains, and naturally cannot obtain the correct Token.
To put it simply: the server sends a one-time ticket to each form. The ticket must be returned when submitting. The attacker cannot forge this ticket.
6.3 When does CSRF need to be turned off?
There is only one situation where CSRF needs to be disabled: third-party callback interfaces (such as payment platform asynchronous notifications), because they cannot provide the Token you generate. Individual exemptions can be made at this time:
from app.extensions import csrf
# 针对单个视图函数关闭 CSRF
@csrf.exempt
@content_bp.route("/api/wechat/pay/callback", methods=["POST"])
def wechat_pay_callback():
# 这里通常会用签名验证来保证安全,比 CSRF 更可靠
pass
It can also be turned off for the entire Blueprint:
⚠️ Never disable CSRF protection without special reasons**. Third-party callback interfaces often provide more stringent alternatives such as signature verification and IP whitelisting.
7. Summary and best practices
7.1 Quick review of usage process
- Install dependencies:
pip install flask-wtf email-validator
- Configuration Key:
app.config["SECRET_KEY"] = "xxxx"and initializeCSRFProtect
- Define form class: inheritance
FlaskForm, populate fields and validators
- Use in routing:
form.validate_on_submit()Judge and get data
- Template Rendering: Must include
{{ form.hidden_tag() }}, then use a macro or draw the field manually
7.2 Golden Rule
- ✅ All addition, deletion and modification interfaces (POST/PUT/DELETE) use Flask-WTF forms and enjoy automatic CSRF protection
- ✅ For production environment
SECRET_KEYMust be read from environment variables, not hardcoded
- ✅ Verification rules require double coverage of front and back ends, and the back end is the final security bottom line.
- ✅ The form is divided into files according to business modules (
forms/auth.py、forms/content.py), easy to maintain
- ✅ Make the error message as specific as possible: clearly state "which field, what reason, and correct approach"
- ✅ Make good use of Jinja2 macros to avoid copying and pasting repeated rendering logic everywhere
🔗 Extended reading