Register Flask-WTF's CSRFProtect(app). It requires the session's token on every POST, PUT, PATCH and DELETE: render {{ csrf_token() }} in a hidden csrf_token field, or send it from scripts in an X-CSRFToken header. Over HTTPS it also requires a same-origin Referer. Change state only in POST views, and set SESSION_COOKIE_SAMESITE = "Lax".
- Install Flask-WTF and register CSRFProtect(app) where the app is created; it protects every view, not only those that use FlaskForm.
- Put <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> in every form that posts, and {{ csrf_meta_tag() }} (Flask-WTF 1.3) in the page head for scripts, which send the value in an X-CSRFToken header.
- Declare state-changing views with @app.post (or methods=["POST"]) and read request.form, never request.values or request.args.
- Set SESSION_COOKIE_SAMESITE = "Lax", SESSION_COOKIE_SECURE = True and SESSION_COOKIE_HTTPONLY = True; Flask sets no SameSite attribute by default.
- Call session.clear() before storing the user at sign-in, so nothing from the anonymous session, the CSRF token included, carries over.
- Optionally register @app.errorhandler(CSRFError) to show a friendly page; keep the response a 400 or 403.
- Add regression tests that send the victim's cookie with the headers a browser sends for a cross-site form, a sibling subdomain, another user's token and a cross-site link.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def email_routes(app: Flask, accounts: dict[str, dict]) -> None:
# GET as well as POST: the link in the "confirm your new address" email uses GET.
@app.route("/account/email", methods=["GET", "POST"])
def change_email():
account = accounts.get(session.get("user", ""))
if account is None:
return "Sign in first", 401
email = request.values.get("email", "") # query string or form body
if len(email) > 254 or not re.fullmatch(r"[^@\s]+@[^@\s]+", email):
return "Enter a valid email address", 400
account["email"] = email
return redirect("/account", 303)With IMPL=vulnerable, 7 of 12 tests fail (7 exploit tests).
The fix
def protect(app: Flask, trusted_origin: str) -> None:
# Every POST, PUT, PATCH and DELETE must carry the token stored in the session, in
# the csrf_token form field or an X-CSRFToken or X-CSRF-Token header. Over HTTPS,
# Flask-WTF also requires a same-origin Referer (WTF_CSRF_SSL_STRICT, on by default).
CSRFProtect(app)With IMPL=fixed, 12 of 12 tests pass.
The fix: change state only on POST
def email_routes(app: Flask, accounts: dict[str, dict]) -> None:
@app.post("/account/email") # POST only, so a link cannot reach it
def change_email():
account = accounts.get(session.get("user", ""))
if account is None:
return "Sign in first", 401
email = request.form.get("email", "")
if len(email) > 254 or not re.fullmatch(r"[^@\s]+@[^@\s]+", email):
return "Enter a valid email address", 400
account["email"] = email
return redirect("/account", 303)With IMPL=fixed, 12 of 12 tests pass.
A fix that looks safe but is not
UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
def protect(app: Flask, trusted_origin: str) -> None:
host = urlsplit(trusted_origin).netloc # "portal.example.test"
@app.before_request
def check_referer():
referer = request.headers.get("Referer")
# Some browsers and privacy tools strip the Referer, so allow requests without one.
if request.method in UNSAFE_METHODS and referer and host not in referer:
return "Cross-site request refused", 403
return NoneWith IMPL=wrong, 3 of 12 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Exploit: each forged request carries Alice's session cookie. Browsers withhold a
# SameSite=Lax cookie from cross-site POSTs, but not from sibling subdomains or top-level
# GET navigations, and older browsers ignore SameSite, so the server must refuse anyway.
def forge(browser, headers, fields=None):
return browser.post("/account/email", headers, {**(fields or {}), "email": "mallory@evil.example"})
def test_exploit_cross_site_form_post_without_a_token_is_refused(sign_in, accounts):
alice = sign_in("alice")
assert_refused(accounts, forge(alice, form_from(EVIL)))
def test_exploit_foreign_origin_is_refused_when_the_browser_sends_no_fetch_metadata(sign_in, accounts):
alice = sign_in("alice")
assert_refused(accounts, forge(alice, {"Origin": EVIL, "Referer": f"{EVIL}/"}))
def test_exploit_sibling_subdomain_is_refused_although_samesite_cookies_reach_it(sign_in, accounts):
alice = sign_in("alice")
assert_refused(accounts, forge(alice, form_from(SIBLING, "same-site")))
def test_exploit_token_from_the_attackers_own_session_is_refused(sign_in, accounts):
bobs_fields = hidden_fields(sign_in("bob").account_page())
alice = sign_in("alice")
assert_refused(accounts, forge(alice, form_from(EVIL), bobs_fields))
def test_exploit_referer_that_merely_contains_the_host_name_is_refused(sign_in, accounts):
alice = sign_in("alice")
assert_refused(accounts, forge(alice, form_from(LOOKALIKE)))
def test_exploit_page_that_suppresses_the_referer_is_refused(sign_in, accounts):
# <meta name="referrer" content="no-referrer"> on the attacking page: no Referer, Origin: null.
alice = sign_in("alice")
headers = {"Origin": "null", "Sec-Fetch-Site": "cross-site", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "document"}
assert_refused(accounts, forge(alice, headers))
def test_exploit_cross_site_get_navigation_cannot_change_the_email(sign_in, accounts):
# Lax cookies are sent on top-level GET navigations, so only the method protects this.
alice = sign_in("alice")
res = alice.get("/account/email?email=mallory%40evil.example", link_from(EVIL))
assert accounts["alice"]["email"] == "alice@example.test", f"a GET changed Alice's email (HTTP {res.status_code})"
assert res.status_code >= 400- Flask has no CSRF protection of its own. A FlaskForm checks its own token on validate_on_submit(), but views that read request.form directly are unprotected until CSRFProtect is registered.
- @app.route(..., methods=["GET", "POST"]) with request.values reads the query string too, so a link can change state, and CSRFProtect never looks at GET (WTF_CSRF_METHODS defaults to POST, PUT, PATCH and DELETE).
- @csrf.exempt, or WTF_CSRF_ENABLED = False copied from test settings into production, switches the check off. Semgrep's flask-wtf-csrf-disabled rule only catches the second.
- Over HTTPS, WTF_CSRF_SSL_STRICT (on by default) also requires a Referer from the same origin, so Referrer-Policy: no-referrer on your own pages breaks every form. Behind a TLS-terminating proxy, use Werkzeug's ProxyFix so request.is_secure is true; otherwise the Referer check is skipped.
- Tokens expire after WTF_CSRF_TIME_LIMIT, 3600 seconds by default: a form left open for an hour fails with 'The CSRF token has expired.' Set it to None to make the token last as long as the session.
- The raw token lives in the session, which by default is Flask's signed session cookie, so the protection is only as good as SECRET_KEY (and WTF_CSRF_SECRET_KEY, which signs the form token if set). Keep the keys secret and the same on every worker.
- A CSRF failure raises CSRFError, a BadRequest, so clients see 400 rather than 403 unless you register an error handler.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs csrf/python-flask does this for you).
- Against your own app, replay a forged post with a signed-in user's session cookie and no token, for example curl -i -b 'session=...' -H 'Origin: https://evil.example' --data 'email=mallory@evil.example' https://your.app/account/email, and expect 400 'The CSRF token is missing.' with no change; repeat as a GET.
- Grep for csrf.exempt, WTF_CSRF_ENABLED and methods=["GET", "POST"]: scanners do not report a Flask app that never registers CSRFProtect.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | python.flask.security.audit.wtf-csrf-disabled.flask-wtf-csrf-disabled | No findings on this card. The rule flags an explicit WTF_CSRF_ENABLED = False; an app that never registers CSRFProtect, like impl/vulnerable.py and impl/wrong.py, is not reported.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | py/csrf-protection-disabled | Not expected to report anything on this card: the only CSRF settings the Python library models are Django's MIDDLEWARE list and its csrf decorators (semmle/python/frameworks/Django.qll); Flask and Flask-WTF are not modelled.Checked: github/codeql main at b5b8165; not executed |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Flask 3.1.3 |
| flask | 3.1.3 |
| flask-wtf | 1.3.0 |
| wtforms | 3.2.2 |
| itsdangerous | 2.2.0 |
| werkzeug | 3.1.9 |
| pytest | 9.1.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the csrf/python-flaskfolder.
python3.14 -m venv .venv
.venv/bin/python -m pip install --require-hashes -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs csrf/python-flask runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python 3.14.8
- pip 26.2.1
- flask 3.1.3
- flaskWtf 1.3.0
- werkzeug 3.1.9
- pytest 9.1.1
- Matrix
- fixed: 12 of 12 tests pass; vulnerable: 7 of 12 tests fail (7 exploit tests); wrong: 3 of 12 tests fail (3 exploit tests)
- Test results
- fixed 12/12 pass; vulnerable 7/12 fail (all 7 exploit tests); wrong 3/12 fail (3 exploit tests), offline after install
- Dependency freshness
- requirements.txt pins all 14 packages with sha256 hashes for every published file. Werkzeug 3.1.9 (2026-09-27) is taken at 13 days old because it fixes GHSA-g6x2-hccm-hh4m; MarkupSafe 3.0.3 and iniconfig 2.3.0 are used instead of 3.0.4 and 2.3.1, which were under 14 days old and fix no security issue. OSV listed no advisories for the pinned versions. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet
- Flask-WTF: CSRF protection (CSRFProtect, csrf_token, csrf_meta_tag, CSRFError)
- Flask-WTF: configuration (WTF_CSRF_METHODS, WTF_CSRF_TIME_LIMIT, WTF_CSRF_SSL_STRICT)
- Flask-WTF 1.3.0 source: flask_wtf/csrf.py
- Flask: configuration handling (SESSION_COOKIE_SAMESITE and related settings)
- MDN: Set-Cookie (SameSite)
- MDN: Referrer-Policy (effect on the Origin header)
Attack patterns: