A Flask pentest is useful when it tries the request you did not write a test for.
The usual mistake is commissioning a scan of the marketing host and never pointing the tester at the app that holds sessions.
This page is the Flask-specific checks a test should include, and the config defaults you should have closed before the tester arrived.
My takeaways
If I kept one Flask test, it would be user B reading user A’s object and expecting 403 or 404. I leave WTF_CSRF_ENABLED on and POST without a token to prove 400. I grep |safe and Markup:
invoice = db.session.get(Invoice, invoice_id)
if invoice is None or invoice.owner_id != user.id:
return None
return invoice
def login(client, user):
token = token_on(client, "/login")
return client.post(
"/login",
data={"email": user.email, "password": user.password, "csrf_token": token},
)
def test_reader_cannot_read_foreign_invoice(client, reader, foreign_invoice):
login(client, reader)
res = client.get(f"/invoices/{foreign_invoice.id}")
assert res.status_code in (403, 404)
def test_reader_cannot_patch_foreign_invoice(client, reader, foreign_invoice):
login(client, reader)
token = csrf_from(client, "/invoices")
paid = client.post(
f"/invoices/{foreign_invoice.id}/pay",
data={"csrf_token": token, "paid": "1"},
)
assert paid.status_code in (403, 404)
Pick 403 if you want the reader to know the row exists. Pick 404 if you do not. Either is a lock. 200 is the miss. Cover GET, POST, PUT, PATCH, and DELETE on the same id. A hidden admin blueprint is the other object: log in as the reader and request /admin/users. Expect 403 or 404, not a redirect to a page that still renders the table.
login and token_on are helpers the suite owns. They use the test client. They do not skip the token. They do not stuff a raw session dict unless you are testing the session interface itself. token_on is the alias the next section defines next to csrf_from.
Keep CSRF on in the suite
Flask does not ship a token. Flask-WTF 1.2 does. CSRFProtect returns 400 with “The CSRF token is missing.” when the field is absent. That response is the assertion. A suite that turns the check off in the fixture never sees it.
Learn from mistake
The happy-path login test fails without a token, so someone flips WTF_CSRF_ENABLED to false on the app object in conftest.py. Production uses the same factory with a forgotten override. The form pages look fine. The settings POST no longer checks.
Keep the flag true. Read the token from a GET you already make, then send it. A second test sends the POST without that field and expects 400.
def csrf_from(client, path):
page = client.get(path)
assert page.status_code == 200
start = page.data.find(b'name="csrf_token"')
assert start != -1
value_at = page.data.find(b'value="', start)
end = page.data.find(b'"', value_at + 7)
return page.data[value_at + 7:end].decode()
def token_on(client, path):
return csrf_from(client, path)
def test_email_post_without_token_is_denied(client, reader):
login(client, reader)
denied = client.post(
"/account/email",
data={"email": "you@your-app.example"},
)
assert denied.status_code == 400
def test_email_post_with_token_saves(client, reader):
login(client, reader)
token = csrf_from(client, "/account/email")
saved = client.post(
"/account/email",
data={"email": "you@your-app.example", "csrf_token": token},
)
assert saved.status_code in (200, 302)
csrf_from is the named fallback for every mutating test. Do not parse with a regex you cannot read. A real HTML parser is fine if you already depend on one. JSON routes that use a cookie session need the same token on X-CSRFToken. A bearer webhook you already authenticate another way can stay @csrf.exempt. Prove that exemption with a test that names the header you require instead.
Hacker NewsRagingCactus ยท October 2025
Notably, it’s called SameSite, NOT SameOrigin. Depending on your application that might matter a lot.
Lax is a belt on the session cookie. It is silent when a sibling host on your eTLD+1 posts to you. The token test above is the second mechanism. Do not delete it because the cookie already says Lax.
SameSite Lax still sends the cookie on a top-level GET. Add a test that a GET handler you care about does not write the database. The CSRF page on this site is the longer argument. This page’s job is to keep the token in the suite.
Fail CI on |safe and Markup
Flask turns autoescape on for .html when you call render_template. {{ bio }} prints text. {{ bio|safe }} marks that value trusted. Markup(...) is the same mark in Python. {% autoescape false %} is a block-level hatch. render_template_string on a user-supplied template is SSTI. None of those belong on request data.
A reviewer will miss a filter in a partial. CI will not, if you fail the build on a hit you have not allowlisted.
from pathlib import Path
HATCHES = ("|safe", "Markup(", "autoescape false", "render_template_string")
def test_templates_do_not_mark_request_html():
root = Path(__file__).resolve().parents[1]
hits = []
for path in root.rglob("*"):
if "venv" in path.parts or ".venv" in path.parts:
continue
if path.suffix not in {".html", ".j2", ".py"}:
continue
text = path.read_text(encoding="utf-8")
for hatch in HATCHES:
if hatch in text:
hits.append(f"{path}:{hatch}")
allowed = set() # add "templates/help.html:|safe" only after a review
leftover = [hit for hit in hits if hit not in allowed]
assert leftover == []
test_templates_do_not_mark_request_html is the corpus check. An empty allowed set is the starting point. A help page that renders HTML you generated through a sanitizer you can name may join that set, with the path and the hatch spelled out. User text from request.args or request.form never does. Quote every attribute. Autoescape does not save an unquoted value={{ name }}.
The key is unset or it is random
The default is None. An unset key fails when you touch session. A tutorial string succeeds and is forgeable. The factory raises if FLASK_SECRET_KEY is missing. The suite must prove both that raise and the absence of a literal.
import os
from flask import Flask
def create_app():
app = Flask(__name__)
secret = os.getenv("FLASK_SECRET_KEY")
if not secret:
raise RuntimeError("missing FLASK_SECRET_KEY")
if secret in {"dev", "changeme", "secret"}:
raise RuntimeError("tutorial SECRET_KEY")
app.config.update(
SECRET_KEY=secret,
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
WTF_CSRF_ENABLED=True,
TRUSTED_HOSTS=[".your-app.example", "your-app.example"],
)
debug = os.environ.get("FLASK_DEBUG") == "1"
app.config["DEBUG"] = debug
if os.environ.get("FLASK_ENV") == "production" and debug:
raise RuntimeError("debug is on in production")
return app
def test_missing_key_refuses_boot(monkeypatch):
monkeypatch.delenv("FLASK_SECRET_KEY", raising=False)
try:
create_app()
except RuntimeError as err:
assert "missing" in str(err)
return
raise AssertionError("factory booted without a key")
def test_repo_has_no_literal_key():
from pathlib import Path
root = Path(__file__).resolve().parents[1]
needles = ("secret_key = '", 'secret_key = "', "SECRET_KEY = '", 'SECRET_KEY = "')
hits = []
for path in root.rglob("*.py"):
if "site-packages" in path.parts:
continue
text = path.read_text(encoding="utf-8")
for needle in needles:
if needle in text and "test_" not in path.name:
hits.append(f"{path}:{needle}")
assert hits == []
create_app is the same factory production uses. The test config may point at a throwaway database. It may not point at a throwaway key that is also committed. Generate a value with python -c 'import secrets; print(secrets.token_hex())' and keep it in the environment the CI job already uses for secrets.
Production debug is a hard fail
The config page: “Do not enable debug mode when deploying in production.” Default: False. The interactive debugger on an unhandled exception is a shell in the response. FLASK_DEBUG and flask run --debug are for a laptop. The factory should refuse the combination you actually ship.
The same create_app already refuses that pair. The test is the proof:
def test_debug_refuses_production(monkeypatch):
monkeypatch.setenv("FLASK_SECRET_KEY", "test-only-not-for-prod")
monkeypatch.setenv("FLASK_ENV", "production")
monkeypatch.setenv("FLASK_DEBUG", "1")
try:
create_app()
except RuntimeError as err:
assert "debug" in str(err)
return
raise AssertionError("factory booted with debug in production")
Serve with gunicorn or uwsgi in the deployment you test, not app.run(). A container that still execs flask run --debug is the miss. Add a smoke test that the process command line in staging does not contain --debug if you own the unit file. Also set TRUSTED_HOSTS in 3.1 when you generate external URLs. Default None accepts any Host header. A password-reset link that trusts Host is an open-redirect adjacent bug. Assert the config in the suite:
def test_trusted_hosts_set(app):
assert app.config.get("TRUSTED_HOSTS")
The pytest shape that holds
One factory. One client fixture that logs nobody in. Two user fixtures. CSRF on. Debug off. Key from the environment. The collection below is the minimum this page will sign.
import pytest
from myapp import create_app
@pytest.fixture
def app(monkeypatch):
monkeypatch.setenv("FLASK_SECRET_KEY", "test-only-not-for-prod")
monkeypatch.setenv("FLASK_ENV", "testing")
application = create_app()
application.config.update(
TESTING=True,
WTF_CSRF_ENABLED=True,
SESSION_COOKIE_SECURE=False, # test client is HTTP
)
yield application
@pytest.fixture
def client(app):
return app.test_client()
SESSION_COOKIE_SECURE=False on the test client is the one relaxation this page accepts, because the client speaks HTTP. Do not copy that line into the production config. Do not set TESTING=True in production to silence a handler. Grep it.
Run the five names on every pull request:
pytest -q tests/test_authz.py tests/test_csrf.py tests/test_templates.py tests/test_factory.py
# Expect: reader denied on foreign invoice
# Expect: POST without csrf_token is 400
# Expect: no leftover |safe
# Expect: unset FLASK_SECRET_KEY raises
# Expect: debug plus production raises
A ZAP baseline against staging can still run after this suite. It is not a substitute. flask-unsign against a cookie you minted with a tutorial key will succeed. That is a key bug, not a reason to publish an unsign walkthrough. Fix the key. Re-run test_missing_key_refuses_boot.
Questions we keep getting
Should we turn CSRF off so pytest can post?
No. Read the token from a GET and send it. The 400 case is the lock. A fixture that disables the check trains the suite to ignore it.
Is a 302 to /login enough for an admin URL?
Only if the next assertion is that the admin table is not in the body. A redirect that still renders the table after follow is a 200 with extra steps. Prefer 403 or 404 from the view.
When is |safe allowed to stay in CI?
When the string is HTML your code produced and you can name the sanitizer. Put that exact path on the allowlist. Never on request.args, request.form, or a database field you did not sanitize on write.
About the author
SC
SecureCoding Team
The SecureCoding editorial team writes for people who ship web software and have to defend it.
Author page