Flask Forms: WTForms, CSRF, and File Uploads
1 · The lesson
readForms are the original web interaction. A user types something, the browser POSTs it, the server reads it, validates it, stores it, redirects. In 1995 you parsed it from application/x-www-form-urlencoded. In 2026 you still do — the protocol hasn't changed, but the libraries are finally pleasant.
This lesson covers server-side forms in Flask: parsing raw input, validating with Flask-WTF, CSRF protection, flash messages, file uploads, and inline error rendering. By the end you'll know when server-rendered forms are the right answer — and when to skip them for a JSON API.
Run locally with
pip install flask flask-wtfandflask run. SetSECRET_KEYin your app config before sessions or CSRF will work — examples assume you have.
1. GET vs POST — Why It Matters
Same data, very different semantics:
| GET | POST | |
|---|---|---|
| Where data goes | Query string in URL | Request body |
| Cacheable | Yes | No |
| Bookmarkable | Yes | No (refresh prompts re-submission) |
| Idempotent | Yes — same call twice = one effect | No — creates a new thing each time |
| Visible in logs | Yes — full URL in access logs | Body usually isn't logged |
| Size limit | Practical limit ~2 KB | Many MB |
| Use for | Searches, filters, navigation | Creating, updating, anything with a side effect |
GET /search?q=python is correct — searching is read-only and bookmarkable. POST /users is correct — creating a user changes state. Mix them up (GET /delete?id=42) and you'll discover the hard way that link-prefetchers and web crawlers happily issue GET requests at every URL they see.
2. The Bare-Bones Form Handler
Without any form library, parsing a POST is two lines:
@app.route("/contact", methods=["GET", "POST"]) def contact(): if request.method == "POST": name = request.form.get("name", "").strip() email = request.form.get("email", "").strip() message = request.form.get("message", "").strip() # Validation, by hand errors = {} if not name: errors["name"] = "Required" if "@" not in email: errors["email"] = "Looks invalid" if len(message) < 10: errors["message"] = "Too short" if errors: return render_template("contact.html", errors=errors, form_data={"name": name, "email": email, "message": message}) save_message(name, email, message) return redirect(url_for("thanks")) return render_template("contact.html", errors={}, form_data={})
setup added so this can run · defines render_template, app, request, save_message, redirect, url_for
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def render_template(*_a, **_kw): print('-> render_template() called') return _AutoMock('render_template()') app = _AutoMock('app') request = _AutoMock('request') def save_message(*_a, **_kw): print('-> save_message() called') return _AutoMock('save_message()') def redirect(*_a, **_kw): print('-> redirect() called') return _AutoMock('redirect()') def url_for(*_a, **_kw): print('-> url_for() called') return _AutoMock('url_for()')
This works. It also has three problems:
- No CSRF protection — any other site can submit this form on your user's behalf (Section 4).
- Validation duplicated in every view — boring, error-prone, untestable.
- Re-rendering with errors is finicky — you have to thread
errorsandform_datathrough the template.
Past three or four forms, this gets ugly fast. Time for a library.
3. Flask-WTF — Forms as Classes
pip install flask-wtf
Flask-WTF is the Flask integration for WTForms. You define a form as a class:
from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, TextAreaField, SubmitField from wtforms.validators import DataRequired, Email, Length class ContactForm(FlaskForm): name = StringField("Name", validators=[DataRequired(), Length(min=2, max=80)]) email = StringField("Email", validators=[DataRequired(), Email()]) message = TextAreaField("Message", validators=[DataRequired(), Length(min=10, max=5000)]) submit = SubmitField("Send")
The view becomes:
from flask import Flask, render_template, redirect, url_for, flash app = Flask(__name__) app.config["SECRET_KEY"] = "change-me-from-env-in-production" @app.route("/contact", methods=["GET", "POST"]) def contact(): form = ContactForm() if form.validate_on_submit(): # True only on a valid POST save_message(form.name.data, form.email.data, form.message.data) flash("Thanks — we'll get back to you.", "success") return redirect(url_for("contact")) return render_template("contact.html", form=form)
setup added so this can run · defines ContactForm, save_message
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def ContactForm(*_a, **_kw): print('-> ContactForm() called') return _AutoMock('ContactForm()') def save_message(*_a, **_kw): print('-> save_message() called') return _AutoMock('save_message()')
Three lines, three responsibilities:
form = ContactForm()— binds incomingrequest.formautomatically. On a GET, the fields are empty. On a POST, they're populated.form.validate_on_submit()— returnsTrueonly when the request is a POST and all validators pass. CSRF is checked here too.form.<field>.data— typed Python value (strfor text,intforIntegerField, etc.).
4. CSRF — What It Is and Why You Need It
Cross-Site Request Forgery: another site tricks your logged-in user's browser into submitting a form to your site. The browser dutifully attaches your session cookie. Your server sees a valid logged-in session and a POST it has no way to distinguish from a legitimate one. Result: the attacker's site just transferred money / changed passwords / deleted account on your user's behalf.
The fix is a per-session token that the attacker can't read (Same-Origin Policy blocks cross-origin reads). The form embeds it; the server verifies it. Flask-WTF does this automatically — you just have to render the token:
<form method="post">
{{ form.csrf_token }} {# THIS — the hidden CSRF token field #}
{{ form.name.label }} {{ form.name() }}
{{ form.email.label }} {{ form.email() }}
{{ form.message.label }} {{ form.message() }}
{{ form.submit() }}
</form>form.csrf_token renders as <input type="hidden" name="csrf_token" value="...">. On submit, WTForms checks it. If it's missing or wrong, validate_on_submit() returns False and the form's errors dict shows the CSRF failure.
Two requirements:
app.config["SECRET_KEY"]must be set. CSRF tokens are signed with it. Load it from an environment variable in production — see devops-secrets.- The form template must render
form.csrf_token. Forget it and every submit fails validation.
For JSON APIs (no browser session), CSRF doesn't apply — you authenticate via tokens, not cookies. See auth-jwt.
5. Rendering the Form
WTForms field objects are callables that emit HTML:
{{ form.name() }} {# <input type="text" name="name"> #}
{{ form.name(class_="form-input", placeholder="Your name") }}
{{ form.name.label }} {# <label for="name">Name</label> #}
{{ form.message(rows=8, cols=40) }} {# extra attrs as kwargs #}Use the class_= keyword (with the underscore) because class is a Python reserved word. The underscore is stripped on output.
The full form template:
{% extends "base.html" %}
{% block content %}
<h1>Contact us</h1>
{# Flash messages, if any #}
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, msg in messages %}
<div class="flash flash-{{ category }}">{{ msg }}</div>
{% endfor %}
{% endwith %}
<form method="post" novalidate>
{{ form.csrf_token }}
<div>
{{ form.name.label }}
{{ form.name(class_="form-input") }}
{% for error in form.name.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.email.label }}
{{ form.email(class_="form-input") }}
{% for error in form.email.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.message.label }}
{{ form.message(rows=6, class_="form-input") }}
{% for error in form.message.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
{{ form.submit(class_="btn") }}
</form>
{% endblock %}The novalidate attribute on <form> turns off browser-side HTML5 validation so you can test your server-side validators without the browser blocking the submit. Remove it in production once you trust both layers.
6. Built-in Validators
The ones you'll use 90% of the time:
from wtforms.validators import ( DataRequired, # value is truthy (rejects "", 0, None) InputRequired, # raw input is non-empty (use over DataRequired for booleans/0) Length, # min/max length on strings NumberRange, # min/max on numeric fields Email, # rudimentary email format URL, # http/https URL Regexp, # regex match EqualTo, # matches another field's value (password confirm) Optional, # allows blank — skips other validators if blank ) class RegisterForm(FlaskForm): username = StringField("Username", validators=[ DataRequired(), Length(min=3, max=30), Regexp(r"^[a-zA-Z0-9_]+$", message="Letters, digits, and underscore only"), ]) password = PasswordField("Password", validators=[ DataRequired(), Length(min=8), ]) confirm = PasswordField("Confirm password", validators=[ DataRequired(), EqualTo("password", message="Passwords must match"), ])
setup added so this can run · defines FlaskForm, StringField, PasswordField
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) FlaskForm = _AutoMock('FlaskForm') def StringField(*_a, **_kw): print('-> StringField() called') return _AutoMock('StringField()') def PasswordField(*_a, **_kw): print('-> PasswordField() called') return _AutoMock('PasswordField()')
Email() does a syntactic check, not deliverability. For real verification you send a confirmation email — see auth-passwords.
7. Custom Validators
When the built-ins aren't enough, write a function. Two patterns:
Field-level — convention: validate_<fieldname>
class RegisterForm(FlaskForm): username = StringField("Username", validators=[DataRequired()]) def validate_username(self, field): # Runs after the standard validators on `username` if User.query.filter_by(username=field.data).first(): raise ValidationError("Username already taken")
setup added so this can run · defines FlaskForm, StringField, ValidationError, DataRequired, User
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) FlaskForm = _AutoMock('FlaskForm') def StringField(*_a, **_kw): print('-> StringField() called') return _AutoMock('StringField()') def ValidationError(*_a, **_kw): print('-> ValidationError() called') return _AutoMock('ValidationError()') def DataRequired(*_a, **_kw): print('-> DataRequired() called') return _AutoMock('DataRequired()') User = _AutoMock('User')
Reusable validator function
from wtforms.validators import ValidationError def must_be_unique(model, column): def _check(form, field): if model.query.filter(getattr(model, column) == field.data).first(): raise ValidationError(f"{column} already in use") return _check class RegisterForm(FlaskForm): username = StringField("Username", validators=[ DataRequired(), must_be_unique(User, "username"), ]) email = StringField("Email", validators=[ DataRequired(), Email(), must_be_unique(User, "email"), ])
setup added so this can run · defines FlaskForm, StringField, DataRequired, User, Email
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) FlaskForm = _AutoMock('FlaskForm') def StringField(*_a, **_kw): print('-> StringField() called') return _AutoMock('StringField()') def DataRequired(*_a, **_kw): print('-> DataRequired() called') return _AutoMock('DataRequired()') User = _AutoMock('User') def Email(*_a, **_kw): print('-> Email() called') return _AutoMock('Email()')
A validator raises ValidationError (or returns silently). The error message ends up in field.errors.
8. Flash Messages
After a successful POST, you redirect (POST-redirect-GET). The next page needs to say "saved!" without keeping the data in the URL. Flash stores a one-shot message in the session:
from flask import flash @app.route("/contact", methods=["GET", "POST"]) def contact(): form = ContactForm() if form.validate_on_submit(): save_message(...) flash("Thanks — we'll get back to you.", "success") return redirect(url_for("contact")) if form.errors: flash("Please fix the errors below.", "error") return render_template("contact.html", form=form)
setup added so this can run · defines ContactForm, render_template, app, save_message, redirect, url_for
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def ContactForm(*_a, **_kw): print('-> ContactForm() called') return _AutoMock('ContactForm()') def render_template(*_a, **_kw): print('-> render_template() called') return _AutoMock('render_template()') app = _AutoMock('app') def save_message(*_a, **_kw): print('-> save_message() called') return _AutoMock('save_message()') def redirect(*_a, **_kw): print('-> redirect() called') return _AutoMock('redirect()') def url_for(*_a, **_kw): print('-> url_for() called') return _AutoMock('url_for()')
Read flashes in the template — once per message, then they're gone:
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, msg in messages %}
<div class="flash flash-{{ category }}">{{ msg }}</div>
{% endfor %}
{% endwith %}Categories let you style different message types — success, error, info, warning. They're plain strings; pick conventions and stick to them across the app.
Flash requires sessions, which require SECRET_KEY. You already set that for CSRF, so it's covered.
9. File Uploads
File inputs need three things: a multipart form, a FileField, and a place to put the file on disk.
import os from flask_wtf.file import FileField, FileRequired, FileAllowed from werkzeug.utils import secure_filename class UploadForm(FlaskForm): photo = FileField("Photo", validators=[ FileRequired(), FileAllowed(["jpg", "jpeg", "png", "webp"], "Images only"), ]) submit = SubmitField("Upload") UPLOAD_DIR = "uploads" app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024 # 5 MB cap on entire request @app.route("/upload", methods=["GET", "POST"]) def upload(): form = UploadForm() if form.validate_on_submit(): f = form.photo.data safe = secure_filename(f.filename) # strips path traversal, weird chars if not safe: flash("Invalid filename", "error") return redirect(url_for("upload")) os.makedirs(UPLOAD_DIR, exist_ok=True) f.save(os.path.join(UPLOAD_DIR, safe)) flash("Uploaded.", "success") return redirect(url_for("upload")) return render_template("upload.html", form=form)
setup added so this can run · defines FlaskForm, SubmitField, app, render_template, flash, redirect, url_for
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) FlaskForm = _AutoMock('FlaskForm') def SubmitField(*_a, **_kw): print('-> SubmitField() called') return _AutoMock('SubmitField()') app = _AutoMock('app') def render_template(*_a, **_kw): print('-> render_template() called') return _AutoMock('render_template()') def flash(*_a, **_kw): print('-> flash() called') return _AutoMock('flash()') def redirect(*_a, **_kw): print('-> redirect() called') return _AutoMock('redirect()') def url_for(*_a, **_kw): print('-> url_for() called') return _AutoMock('url_for()')
In the template, the form tag must have enctype="multipart/form-data" — otherwise the file isn't sent:
<form method="post" enctype="multipart/form-data">
{{ form.csrf_token }}
{{ form.photo() }}
{{ form.submit() }}
</form>Three things to lock down:
MAX_CONTENT_LENGTH— Flask aborts the request with413 Request Entity Too Largebefore reading the full body. Without it, a 10-GB upload exhausts your server's memory or disk.secure_filename— strips../, slashes, and unusual characters. Never trustf.filenamedirectly.- Extension and content-type checks —
FileAllowedlooks at the filename. Determined attackers rename.exeto.png. For untrusted uploads in production, also sniff the bytes (python-magic) and store outside the web root.
10. Rendering Field Errors Inline
The pattern from Section 5 — {% for error in form.<field>.errors %} — works field-by-field. For a "show all errors at the top" summary:
{% if form.errors %}
<div class="flash flash-error">
<ul>
{% for field_name, errs in form.errors.items() %}
{% for err in errs %}
<li>{{ form[field_name].label.text }}: {{ err }}</li>
{% endfor %}
{% endfor %}
</ul>
</div>
{% endif %}form.errors is a dict of {field_name: [error_strings]}. Empty until you call form.validate_on_submit() (or form.validate()).
11. AJAX Form Submission
For a richer UX, submit via fetch and respond with JSON instead of a redirect:
@app.post("/contact/ajax") def contact_ajax(): form = ContactForm() if form.validate_on_submit(): save_message(form.name.data, form.email.data, form.message.data) return {"ok": True} return {"ok": False, "errors": form.errors}, 400
setup added so this can run · defines ContactForm, app, save_message
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def ContactForm(*_a, **_kw): print('-> ContactForm() called') return _AutoMock('ContactForm()') app = _AutoMock('app') def save_message(*_a, **_kw): print('-> save_message() called') return _AutoMock('save_message()')
Client-side:
const form = document.querySelector("#contact-form");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const data = new FormData(form);
const res = await fetch(form.action, { method: "POST", body: data });
const body = await res.json();
if (body.ok) {
showSuccess("Thanks!");
} else {
showErrors(body.errors); // {field_name: ["msg", ...]}
}
});FormData(form) includes the CSRF token field automatically because it's part of the form. No extra wiring needed.
12. The Honest Note — SPA Frontends
If your frontend is React, Vue, Svelte, or any other SPA framework, you usually skip server-rendered forms entirely. The flow becomes:
1. SPA collects input client-side.
2. Sends JSON to a REST or GraphQL endpoint.
3. Server validates with Pydantic (in FastAPI), Marshmallow, or DRF serializers (Django).
4. Returns JSON; SPA renders the result.
CSRF protection in that world uses token-based auth (JWT in Authorization: Bearer) rather than cookies. Server-rendered forms are still the right answer for traditional multi-page apps, admin panels, and most internal tools — they're shorter, more accessible by default, and don't require a JavaScript build pipeline.
See fastapi-basics for the JSON-API counterpart, and django-rest for Django's serializer-driven approach.
13. Common Mistakes
1. Missing CSRF token in the template
form.validate_on_submit() silently returns False and you spend an hour debugging why "the form never saves". Always render {{ form.csrf_token }} inside the <form>. The error will be in form.errors["csrf_token"] if you ever need to debug it.
2. Not escaping user input
Jinja auto-escapes {{ user_input }} by default. {{ user_input|safe }} and {% autoescape false %} turn it off. Use them only for HTML you produced yourself (e.g. markdown you sanitised through bleach). Stored XSS lives in this gap. See security-checklist.
3. Accepting uploads without limits
No MAX_CONTENT_LENGTH, no FileAllowed, no secure_filename — that's three different vulnerabilities in three lines: memory exhaustion, malware ingestion, path traversal. Set all three for any file upload.
4. DataRequired on boolean / numeric fields
DataRequired checks truthiness — it rejects 0 and False. For a numeric field where 0 is a valid value, use InputRequired instead (checks that something was submitted, not that it's truthy).
5. Using GET for state-changing actions
GET /delete?id=42 runs every time a link prefetcher, crawler, or browser pre-fetch hits the URL. Use POST for anything that changes state. Add a confirmation step for destructive actions — a CSRF-protected form, not a bare link.
6. Storing uploaded files in the static directory
Files in static/ are served to anyone who knows the URL. Uploaded user content should go in a separate directory served by an explicit view that checks authorisation, or behind nginx with an internal location and X-Accel-Redirect.
7. Re-validating client-side only
Browser-side validation (required, type=email, pattern=...) is a UX nicety — anyone can disable it with DevTools or skip the browser entirely. Server-side validation is the only validation. Client-side is decoration.
🎯 Your Turn — A Contact Form with WTForms
Build a contact form at /contact with:
- Fields:
name(3-80 chars),email(valid format),message(10-2000 chars). - CSRF protection (just render the token — Flask-WTF does the rest).
- Server-side validation; render inline errors next to each field.
- Flash a success message on successful submit, then redirect to
/contact(POST-redirect-GET). - A trivial
save_message(name, email, message)function — append to a list is fine.
Skeleton:
# app.py from flask import Flask, render_template, redirect, url_for, flash from flask_wtf import FlaskForm from wtforms import StringField, TextAreaField, SubmitField from wtforms.validators import DataRequired, Email, Length app = Flask(__name__) app.config["SECRET_KEY"] = "dev-only-change-me" messages = [] # in-memory store class ContactForm(FlaskForm): # TODO 1: define name, email, message, submit with validators pass def save_message(name, email, message): messages.append({"name": name, "email": email, "message": message}) @app.route("/contact", methods=["GET", "POST"]) def contact(): form = ContactForm() # TODO 2: if form.validate_on_submit(): # save it, flash a success message, redirect to /contact # TODO 3: otherwise render contact.html with form pass
Template skeleton (templates/contact.html):
{% extends "base.html" %}
{% block content %}
{# TODO 4: render flash messages with categories #}
<form method="post" novalidate>
{# TODO 5: csrf token, each field with label, input, inline errors #}
</form>
{% endblock %}Hint 1 — Defining the form class
Inherit fromFlaskForm. Each field is a class attribute: name = StringField("Name", validators=[DataRequired(), Length(min=3, max=80)]). email uses Email(), message uses TextAreaField with Length(min=10, max=2000). Finish with submit = SubmitField("Send").
Hint 2 — POST-redirect-GET
Aftersave_message(...) and flash(...), return redirect(url_for("contact")). The redirect makes the browser fetch the page fresh as a GET, so a refresh doesn't resubmit. Don't return render_template(...) on the success path — that breaks the pattern and re-submits on refresh.
Show full solution
# app.py from flask import Flask, render_template, redirect, url_for, flash from flask_wtf import FlaskForm from wtforms import StringField, TextAreaField, SubmitField from wtforms.validators import DataRequired, Email, Length app = Flask(__name__) app.config["SECRET_KEY"] = "dev-only-change-me" messages = [] class ContactForm(FlaskForm): name = StringField("Name", validators=[ DataRequired(), Length(min=3, max=80), ]) email = StringField("Email", validators=[ DataRequired(), Email(message="Must be a valid email address"), ]) message = TextAreaField("Message", validators=[ DataRequired(), Length(min=10, max=2000), ]) submit = SubmitField("Send") def save_message(name, email, message): messages.append({"name": name, "email": email, "message": message}) @app.route("/contact", methods=["GET", "POST"]) def contact(): form = ContactForm() if form.validate_on_submit(): save_message(form.name.data, form.email.data, form.message.data) flash("Thanks — we'll get back to you soon.", "success") return redirect(url_for("contact")) if form.errors: flash("Please fix the errors below.", "error") return render_template("contact.html", form=form) if __name__ == "__main__": app.run(debug=True)
templates/base.html:
<!doctype html>
<html>
<head>
<title>Contact</title>
<style>
.flash { padding: .5em; margin: .5em 0; }
.flash-success { background: #e8f5e9; }
.flash-error { background: #ffebee; }
.error { color: #c62828; font-size: 90%; }
label { display: block; margin-top: 1em; }
input, textarea { width: 100%; max-width: 400px; }
</style>
</head>
<body>{% block content %}{% endblock %}</body>
</html>templates/contact.html:
{% extends "base.html" %}
{% block content %}
<h1>Contact us</h1>
{% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, msg in msgs %}
<div class="flash flash-{{ cat }}">{{ msg }}</div>
{% endfor %}
{% endwith %}
<form method="post" novalidate>
{{ form.csrf_token }}
<div>
{{ form.name.label }}
{{ form.name() }}
{% for err in form.name.errors %}<span class="error">{{ err }}</span>{% endfor %}
</div>
<div>
{{ form.email.label }}
{{ form.email() }}
{% for err in form.email.errors %}<span class="error">{{ err }}</span>{% endfor %}
</div>
<div>
{{ form.message.label }}
{{ form.message(rows=6) }}
{% for err in form.message.errors %}<span class="error">{{ err }}</span>{% endfor %}
</div>
{{ form.submit() }}
</form>
{% endblock %}What this gets right:
- CSRF —
{{ form.csrf_token }}rendered,SECRET_KEYset. - Validation in one place — the form class. Every view that uses
ContactFormgets identical rules. - POST-redirect-GET — refresh after submit doesn't re-send.
- Flash messages with categories — success and error styled differently.
- Inline field errors — user sees exactly which field is wrong without scrolling to a list.
- Auto-escaping — even if a user submits
<script>in their message, it renders harmlessly when you display the messages list back.
What's missing for production:
- Real persistence —
messagesis in-memory. Replace with the DB layer from flask-database. - Rate limiting — without it, anyone can spam the form.
Flask-Limiterhandles this. - Email delivery — actually email the team.
Flask-Mailor a transactional provider (SendGrid, Postmark). - Honeypot or hCaptcha — bots fill every form they find.
What You Learned
- GET reads, POST writes. Use the right verb — link prefetchers will hit every URL they see.
- Flask-WTF wraps WTForms — define forms as classes, get validation, CSRF protection, and rendering helpers.
form.validate_on_submit()isTrueonly on a valid POST. CSRF, validators, and method check are all rolled into it.- CSRF is automatic with Flask-WTF — render
{{ form.csrf_token }}and setSECRET_KEY. Forget either and submits fail silently. - Field rendering: call the field (
form.name()), pass HTML attrs as kwargs, useclass_=forclass. - Validators:
DataRequired,Length,Email,Regexp,EqualTo,Optional. Add custom ones viavalidate_<field>methods or reusable functions raisingValidationError. - Flash messages + POST-redirect-GET — show success after submit without re-posting on refresh.
- File uploads —
FileField,enctype="multipart/form-data",MAX_CONTENT_LENGTH,secure_filename,FileAllowed. All four are mandatory. - Server-side validation is the only validation. Client-side is UX, not security.
Next: flask-database — replace the in-memory list with SQLAlchemy models, real persistence, and migrations.