Flask Forms: WTForms, CSRF, and File Uploads
1 · The lesson
readForms are the oldest kind of web interaction. A user types something, the browser POSTs it, and the server reads it, validates it, stores it and 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 now much easier to use.
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 choice, and when to use a JSON API instead.
Run locally with
pip install flask flask-wtf email-validatorandflask run. Sessions and CSRF only work after you setSECRET_KEYin your app config. The examples assume you have set it.
1. GET vs POST: Why It Matters
The same data can be sent either way, but the two methods mean very different things:
| 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, because searching only reads data and the URL can be bookmarked. POST /users is correct, because creating a user changes state. If you mix them up (GET /delete?id=42), you will find out the hard way that link prefetchers and web crawlers send GET requests to every URL they find.
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 is duplicated in every view, which is repetitive, error-prone and hard to test.
- Re-rendering with errors is fiddly: you have to pass
errorsandform_datato the template every time.
After three or four forms, this becomes messy. This is where a library helps.
3. Flask-WTF: Forms as Classes
pip install flask-wtf email-validator # Email() needs email-validator
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()reads the 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>.datais the 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 automatically attaches your session cookie. Your server sees a valid logged-in session and a POST that it cannot tell apart from a real one. The result: the attacker's site has transferred money, changed a password or deleted an 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, and the server verifies it. Flask-WTF does this automatically. You only have to render the token:
<form method="post">
{{ form.csrf_token }} {# this line adds 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. If you forget it, every submit fails validation.
For JSON APIs (no browser session), CSRF doesn't apply, because you authenticate with 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 HTML attributes as keyword arguments #}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 stopping the submit. Remove it in production once you trust both layers.
6. Built-in Validators
The ones you'll use most often:
from wtforms.validators import ( DataRequired, # value is truthy (rejects "", 0, None) InputRequired, # raw input is not empty (use it instead of DataRequired for booleans and 0) Length, # min/max length on strings NumberRange, # min/max on numeric fields Email, # basic email format check URL, # http/https URL Regexp, # regex match EqualTo, # matches another field's value (password confirm) Optional, # allows a blank value, and then skips the other validators ) 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() only checks the format of the address, not whether email can be delivered to it. To really verify an address, send a confirmation email. See auth-passwords.
7. Custom Validators
When the built-ins aren't enough, write a function. Two patterns:
Field-level, by naming 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 when the value is invalid, and returns normally when it is valid. 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 message in the session that is shown only once:
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 flashed messages in the template. Each message is read once, and then it is removed:
{% 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, so choose a set of names and use the same ones across the app.
Flash requires sessions, which require SECRET_KEY. You already set it for CSRF, so nothing more is needed.
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) # removes path parts such as ../ and unsafe characters 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 secure:
MAX_CONTENT_LENGTH: Flask stops the request with413 Request Entity Too Largebefore reading the full body. Without it, a 10 GB upload can use up your server's memory or disk.secure_filename: removes../, slashes, and unusual characters. Never trustf.filenamedirectly.- Extension and content-type checks:
FileAllowedonly looks at the filename, and an attacker can just rename.exeto.png. For untrusted uploads in production, also check the file's bytes (python-magic) and store the files outside the web root.
10. Rendering Field Errors Inline
The pattern from Section 5 ({% for error in form.<field>.errors %}) works one field at a time. 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 smoother user experience, submit with 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. You don't need to add anything.
12. A Note on 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, and the SPA renders the result.
In that setup, CSRF is usually avoided by using token-based auth (a JWT in Authorization: Bearer) instead of cookies. Server-rendered forms are still the right choice for traditional multi-page apps, admin panels, and most internal tools. They need less code, are more accessible by default, and don't need 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() returns False with no visible error, and you spend an hour finding out 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). This is where stored XSS vulnerabilities come from. See security-checklist.
3. Accepting uploads without limits
No MAX_CONTENT_LENGTH, no FileAllowed, no secure_filename: that's three different vulnerabilities (memory exhaustion, accepting malware, and path traversal). Set all three for any file upload.
4. DataRequired on boolean / numeric fields
DataRequired checks truthiness, so it rejects 0 and False. For a numeric field where 0 is a valid value, use InputRequired instead. It checks that something was submitted, not that the value is 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 plain 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. Validating on the client side only
Browser-side validation (required, type=email, pattern=...) only makes the form easier to use. Anyone can disable it with DevTools, or send a request without a browser. Only server-side validation protects your app.
🎯 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, and Flask-WTF does the rest).
- Server-side validation, with inline errors rendered next to each field.
- Flash a success message on successful submit, then redirect to
/contact(POST-redirect-GET). - A simple
save_message(name, email, message)function. Appending 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 load the page again as a GET, so a refresh doesn't submit the form again. Don't return render_template(...) on the success path. That breaks the pattern, and a refresh would submit the form again.
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 }}is rendered andSECRET_KEYis set. - Validation in one place: the form class. Every view that uses
ContactFormgets identical rules. - POST-redirect-GET: refreshing after a submit doesn't send the form again.
- Flash messages with categories: success and error messages are styled differently.
- Inline field errors: the user sees exactly which field is wrong, without scrolling to a list.
- Auto-escaping: even if a user submits
<script>in their message, it is displayed as harmless text when you show the list of messages.
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: send the message to the team by email.
Flask-Mailor a transactional provider (SendGrid, Postmark). - Honeypot or hCaptcha: bots automatically fill in forms they find.
What You Learned
- GET reads, POST writes. Use the right method, because link prefetchers request every URL they find.
- Flask-WTF wraps WTForms. You define forms as classes and get validation, CSRF protection, and rendering helpers.
form.validate_on_submit()isTrueonly on a valid POST. It checks the method, the CSRF token and the validators.- CSRF is automatic with Flask-WTF: render
{{ form.csrf_token }}and setSECRET_KEY. If you forget either one, submits fail. - 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 a success message after a submit, without posting again on refresh.
- File uploads:
FileField,enctype="multipart/form-data",MAX_CONTENT_LENGTH,secure_filename,FileAllowed. You need all of them. - Server-side validation is the only validation. Client-side validation helps the user, but it does not protect your app.
Next: flask-database: replace the in-memory list with SQLAlchemy models, real persistence, and migrations.
Found this useful? Share it with a friend who’s learning Python.