Skip to content

CSRF protection

Severity: mediumApplies to: Flask 3.1.3Applies to: Flask-WTF 1.3.0Facts last verified 2026-08-13 against Flask 3.1.3 · Flask-WTF 1.3.0

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.

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.

settings on this page
WTF_CSRF_ENABLED

Master 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)
Read in: Flask-WTF 1.3.0 flask_wtf/csrf.py:213 — config.setdefault("WTF_CSRF_ENABLED", True)
WTF_CSRF_CHECK_DEFAULT

Protects 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)
Read in: Flask-WTF 1.3.0 flask_wtf/csrf.py:214
WTF_CSRF_METHODS

Methods 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)
Read in: Flask-WTF 1.3.0 flask_wtf/csrf.py:215-217
WTF_CSRF_TIME_LIMIT

Token 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)
Read in: Flask-WTF 1.3.0 flask_wtf/csrf.py:222
WTF_CSRF_SSL_STRICT

Checks 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)
Read in: Flask-WTF 1.3.0 flask_wtf/csrf.py:223
WTF_CSRF_HEADERS

Headers 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)
Read in: Flask-WTF 1.3.0 flask_wtf/csrf.py:220
WTF_CSRF_FIELD_NAME

Form field name the token is read from.

accepts
"csrf_token"
default
"csrf_token"
set in
app.config (Flask-WTF)
Read in: Flask-WTF 1.3.0 flask_wtf/csrf.py:218
the fix
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect(app) # every POST/PUT/PATCH/DELETE now requires a token

CSRFProtect 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),
});

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.

verify it workedRun this in: http response
Terminal window
# a state-changing request with a real session cookie and no token
curl -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:

Terminal window
python3 -c "
from app import app
for 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.

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.

before you ship this

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:

Terminal window
awk '$6 !~ /GET/ {print $NF}' access.log | sort | uniq -c | sort -rn | head -20

Roll 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.

  • Session cookies — where SameSite is set, which is the layer underneath this
  • SECRET_KEY — what signs the CSRF token as well as the session