CSRF protection
Flask ships no CSRF protection. Grepping the installed package for the string returns nothing at all — not a helper, not a hook, not a mention in a docstring.
That is consistent with what Flask is, and it means the control is entirely yours. Because Flask sessions are cookie-based, an application using them is in the exposed case by default.
Two layers, and the first one is free
Section titled “Two layers, and the first one is free”SameSite=Lax on the session cookie stops the cookie being attached to cross-site
POST, PUT, PATCH and DELETE — which is the shape almost every classic CSRF
takes.
Flask does not set the attribute by default (see session cookies), so this layer is one config line away and worth having regardless of what else you do. Modern browsers apply Lax when the attribute is absent, so you may already have the behaviour — the config line is what makes it a decision rather than an inheritance.
A token is the layer that does not depend on browser defaults, and it is what covers
the gap: Lax still permits top-level GET navigation, and it does nothing if you have
set SameSite=None to support a cross-origin front end.
WTF_CSRF_ENABLEDMaster switch, on once CSRFProtect is initialised. The thing to know is that nothing in Flask core provides this — a grep for csrf across the installed flask package returns nothing at all, so if Flask-WTF is not installed there is no switch to be on.
- accepts
True|False- default
True- set in
app.config (Flask-WTF)
WTF_CSRF_CHECK_DEFAULTProtects every view automatically rather than only those you decorate. Turning it off to unblock one API endpoint silently unprotects the whole application — exempt the single view instead.
- accepts
True|False- default
True- set in
app.config (Flask-WTF)
WTF_CSRF_METHODSMethods that get checked. GET is deliberately absent, which is correct only while your GET handlers change nothing — a state-changing GET is outside this protection by design.
- accepts
{"POST", "PUT", "PATCH", "DELETE"}- default
["POST", "PUT", "PATCH", "DELETE"]- set in
app.config (Flask-WTF)
WTF_CSRF_TIME_LIMITToken lifetime, one hour. This is the source of the classic complaint that a long form fails on submit — the user was slower than the token.
- accepts
3600|any int, seconds|None (no expiry)- default
3600- set in
app.config (Flask-WTF)
WTF_CSRF_SSL_STRICTChecks the Referer on HTTPS requests. On by default; it is also what breaks behind a proxy that rewrites or strips Referer, and the failure looks like a token problem rather than a header one.
- accepts
True|False- default
True- set in
app.config (Flask-WTF)
WTF_CSRF_HEADERSHeaders accepted as the token for AJAX requests. Both spellings are accepted by default, which is worth knowing before adding a third.
- accepts
["X-CSRFToken", "X-CSRF-Token"]- default
["X-CSRFToken", "X-CSRF-Token"]- set in
app.config (Flask-WTF)
WTF_CSRF_FIELD_NAMEForm field name the token is read from.
- accepts
"csrf_token"- default
"csrf_token"- set in
app.config (Flask-WTF)
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect(app) # every POST/PUT/PATCH/DELETE now requires a tokenCSRFProtect is the whole-application form and the one to prefer. It hooks every
state-changing request rather than every form, so a new route is protected without
anyone remembering.
In templates, emit the token:
<form method="post"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"></form>For a JavaScript client, send it as a header — Flask-WTF accepts X-CSRFToken:
fetch("/api/thing", { method: "POST", headers: { "X-CSRFToken": csrfToken, "Content-Type": "application/json" }, body: JSON.stringify(data),});FlaskForm alone is not the same thing
Section titled “FlaskForm alone is not the same thing”Using FlaskForm gives each form a CSRF token and validates it in
form.validate_on_submit(). That is real protection for the routes that use forms.
It leaves uncovered every route that does not: a JSON API endpoint, a POST handled by
reading request.form directly, a webhook receiver, a small @app.post that toggles
something. Those are exactly the routes that accumulate over time, and none of them
errors for lack of a token.
CSRFProtect is the difference between opt-in per form and enforced by default, which
is why the Fix above is the extension rather than the form class.
# a state-changing request with a real session cookie and no tokencurl -s -o /dev/null -w '%{http_code}\n' -X POST \ https://app.example.com/account/email \ -H 'Cookie: session=<a real session cookie>' \ --data 'email=attacker@evil.example'400 is the correct answer — Flask-WTF rejects a missing token with 400, not 403. A
200 means that route accepts a request another site could have caused.
Enumerate the routes that change state and check they are all covered:
python3 -c "from app import appfor r in app.url_map.iter_rules(): verbs = r.methods - {'HEAD','OPTIONS'} if verbs - {'GET'}: print(f'{sorted(verbs)!s:28} {r.rule}')"Every line there needs either a token or a deliberate exemption.
What to exempt, and how
Section titled “What to exempt, and how”Some routes genuinely cannot carry a token: an inbound webhook from a payment provider, a callback from an identity provider, a machine-to-machine endpoint authenticated by bearer token.
@csrf.exempt@app.post("/webhooks/stripe")def stripe_webhook(): ...An exemption is a decision to protect that route another way. For a webhook that means verifying the sender’s signature; for a token-authenticated API it means the credential is not ambient in the first place, so CSRF does not apply.
An exemption with nothing behind it is just an unprotected route with a decorator on it.
Enabling CSRFProtect rejects every existing client that does not send a token, and the
ones that break are rarely the browser forms — those get the token from the template. It
is the JSON front end, the mobile app, the integration tests, the cron job posting to an
internal endpoint, and every webhook.
The failure is a 400 with a short body, which client code often reports as a generic
error, so the diagnosis starts in the wrong place.
Find the callers before you enforce. If your access log records them, the non-GET requests grouped by user agent will show you what to expect:
awk '$6 !~ /GET/ {print $NF}' access.log | sort | uniq -c | sort -rn | head -20Roll out by exempting the known machine callers first, enabling protection, then removing exemptions one at a time as each caller learns to send the token.
Tokens are tied to the session, so they expire with it — a form left open past the
session lifetime fails on submit. That is correct behaviour and a poor experience;
WTF_CSRF_TIME_LIMIT controls the token’s own window independently of the session.
Related
Section titled “Related”- Session cookies — where SameSite is set, which is the layer underneath this
- SECRET_KEY — what signs the CSRF token as well as the session