Flask-WTF forms with custom validators, CSRF on and off in tests: working versions (tested 2026-10-15)
A Flask-WTF form with a reusable custom WTForms validator, rendered errors, and CSRF protection, tested offline with the Flask test client: CSRF disabled in the test config, then enforced.
passed4 passed · 0 failed2026-10-15 13:22 UTC- Status
- passed
- Last passed
- 2026-10-15 13:22 UTC
- Tests
- 4 / 4
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38741369695
The code
Exactly the files that passed. Your agent can pull them with get_recipe("flask-wtforms-csrf-validation") (install), or see them on GitHub.
flask==3.1.3 flask-wtf==1.3.0 wtforms==3.2.2 pytest==9.1.1
import re
from flask import Flask, render_template_string, request
from flask_wtf import FlaskForm
from wtforms import IntegerField, StringField, SubmitField
from wtforms.validators import DataRequired, NumberRange, ValidationError
SLUG = re.compile(r"^[a-z0-9-]+$")
def slug_format(form, field):
# Reusable custom validator: raise ValidationError to attach a message to the field.
if not SLUG.match(field.data or ""):
raise ValidationError("Use lowercase letters, digits and dashes only.")
class ProjectForm(FlaskForm):
# filters run before validators, so whitespace-only names fail DataRequired.
name = StringField("Name", filters=[lambda s: s.strip() if s else s],
validators=[DataRequired()])
slug = StringField("Slug", validators=[DataRequired(), slug_format])
seats = IntegerField("Seats", validators=[NumberRange(min=1, max=50)])
submit = SubmitField("Create")
TEMPLATE = """
<form method="post">
{{ form.csrf_token }}
{% for field in [form.name, form.slug, form.seats] %}
<label>{{ field.label }} {{ field() }}</label>
{% endfor %}
{{ form.submit() }}
{% for msgs in form.errors.values() %}{% for e in msgs %}<p class="error">{{ e }}</p>{% endfor %}{% endfor %}
</form>
"""
def create_app(config=None):
app = Flask(__name__)
# Flask-WTF reads WTF_CSRF_ENABLED per form instance, so set it before requests.
# Note: TESTING=True does NOT disable CSRF; only WTF_CSRF_ENABLED does.
app.config.update(SECRET_KEY="dev", TESTING=True)
app.config.update(config or {})
@app.route("/projects/new", methods=["GET", "POST"])
def new_project():
form = ProjectForm()
if form.validate_on_submit():
return f"created {form.slug.data}", 201
status = 422 if request.method == "POST" else 200
return render_template_string(TEMPLATE, form=form), status
return app
import re
import pytest
from app import create_app
VALID = {"name": "Demo", "slug": "demo-1", "seats": "5"}
TOKEN = re.compile(r'name="csrf_token"[^>]*value="([^"]+)"')
@pytest.fixture
def client():
# CSRF disabled for this app only; the form is validated without a token.
return create_app({"WTF_CSRF_ENABLED": False}).test_client()
@pytest.fixture
def app():
# Default config: CSRF enforced.
return create_app()
def test_csrf_disabled_in_test_config(client):
r = client.post("/projects/new", data=VALID)
assert r.status_code == 201
assert b"demo-1" in r.data
def test_csrf_enforced_rejects_missing_token(app):
r = app.test_client().post("/projects/new", data=VALID)
assert r.status_code == 422
assert b"CSRF" in r.data
def test_token_is_session_bound(app):
# The token is tied to the session cookie: the same client succeeds,
# a fresh client presenting the same token is rejected.
c = app.test_client()
page = c.get("/projects/new").get_data(as_text=True)
token = TOKEN.search(page).group(1)
assert c.post("/projects/new", data={**VALID, "csrf_token": token}).status_code == 201
other = app.test_client()
assert other.post("/projects/new", data={**VALID, "csrf_token": token}).status_code == 422
def test_custom_validator_errors_rendered(client):
# Whitespace-only name is stripped by the filter, then fails DataRequired.
r = client.post("/projects/new", data={"name": " ", "slug": "Bad Slug", "seats": "5"})
assert r.status_code == 422
assert b"lowercase letters" in r.data
assert b"This field is required." in r.data
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Flask 3 + Flask-JWT-Extended access and refresh tokens
Login, protected routes, refresh tokens and custom claims in Flask 3 using Flask-JWT-Extended, with an app factory.
Flask 3 app factory + Flask-SQLAlchemy
Flask application factory with Flask-SQLAlchemy 3, a JSON API and pytest fixtures using an in-memory database.
Flask API args parsed and validated with webargs and marshmallow
Flask routes that parse query strings and JSON bodies through marshmallow schemas via webargs, with JSON 422 validation errors, tested offline with the Flask test client.
Flask API with Flask-SQLAlchemy and marshmallow-sqlalchemy, validation errors as 422
A Flask JSON API that loads and dumps SQLAlchemy models through SQLAlchemyAutoSchema, returns validation errors as 422, and is tested offline with the Flask test client on in-memory sqlite.