
Flask will not set a production SECRET_KEY for you. A missing key or a tutorial key is a forged-session bug.
SESSION_COOKIE_SECURE defaults to off. CSRF is an extension you add, not a built-in. Debug on in production prints traces. Those are the first four settings, before you talk about OAuth.
The usual mistake is a blog that sets app.secret_key = 'dev' and a deploy that never overrides it.
This page is the Flask config that belongs in production and the extensions that close CSRF and login.
SECRET_KEY defaults to None in Flask 3.1. stable config page. A tutorial that then sets app.secret_key = "dev" signs every session cookie with a string the whole internet has seen. The other miss is {{ bio|safe }}: one filter turns autoescape off for that value while the rest of the template still looks locked.
Flask 3.x still does not turn those locks on for you. The Pallets security page is blunt about CSRF: the ideal place for a token is a form library, and Flask does not ship one. Keep session management next to this page for the cookie model. Read XSS for the HTML sink, CSRF for the browser-attached cookie, and injection for the query string.
SECRET_KEY is the whole signature
Flask’s default session is a client-side cookie signed with itsdangerous. The docs say the user can read the contents and cannot modify them unless they know the key. That sentence is the product. The key is the product. Default: None.
Flask 3 leaves SECRET_KEY at None and Secure off. The third switch is |safe, and it starts on the hatch side.
SecureCoding
An unset key fails when you touch session. An empty string or a tutorial value succeeds and is forgeable. Grep for secret_key = and SECRET_KEY =. A literal in the repo is the miss, including the example bytes on the quickstart page if someone committed them.
import os
from flask import Flask
def create_app():
app = Flask(__name__)
secret = os.environ.get("FLASK_SECRET_KEY")
if not secret:
raise RuntimeError("missing FLASK_SECRET_KEY")
app.config.update(
SECRET_KEY=secret,
SECRET_KEY_FALLBACKS=_fallback_keys(),
)
return app
def _fallback_keys():
raw = os.environ["FLASK_SECRET_KEY_FALLBACKS"] if "FLASK_SECRET_KEY_FALLBACKS" in os.environ else ""
return [item for item in raw.split(",") if item]
_fallback_keys is the named helper. Flask 3.1 added SECRET_KEY_FALLBACKS so you can rotate without dropping every live cookie. Put the old key in that list, ship, then remove it after the cookie lifetime. Do not keep a fallback forever. Each extra key is extra unsigning work and extra material to leak.
Generate a value with the command the config page prints:
python -c 'import secrets; print(secrets.token_hex())'
Put it in the environment, not in git. Do not reuse it as the CSRF secret if you can set WTF_CSRF_SECRET_KEY separately. One leak then burns both jobs.
Set the cookie flags Flask leaves open
I copied these defaults from the same 3.1 config page:
SESSION_COOKIE_HTTPONLY:TrueSESSION_COOKIE_SECURE:FalseSESSION_COOKIE_SAMESITE:NoneSESSION_COOKIE_DOMAIN:None(exact host, which is the tighter setting)SESSION_COOKIE_PARTITIONED:False(added in 3.1)PERMANENT_SESSION_LIFETIME: 31 days
HttpOnly is already on. Secure is not, because Flask cannot know at import time whether you terminate TLS. SameSite is not, so the browser default is what you get. The security page’s recommended block is the one to copy:
app.config.update(
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
PERMANENT_SESSION_LIFETIME=1800,
)
Lax stops a foreign POST from riding the cookie. It still sends the cookie on a top-level GET. Do not change state on GET. Strict also withholds the cookie on a click from another site, which breaks “open this email link already logged in.” Pick Lax unless you can name the flow that needs Strict.
Leave SESSION_COOKIE_DOMAIN unset. A leading-dot domain shares the cookie with every sibling host. That is a cookie-toss surface. If you need a sibling to share a login, you have a product problem, not a default to flip.
SESSION_COOKIE_PARTITIONED is for a cookie you intend to set inside a third-party iframe under CHIPS. Enabling it forces Secure. Most first-party apps leave it false.
The session cookie is still readable by the client. Do not store a password hash, a reset token, or a role you cannot afford to show. Store a user id. Reload authority on the server. That is the half of lifeisstillgood’s comment that still holds in 2026.
CSRF is not in core. Flask-WTF is
Pallets writes: “Why does Flask not do that for you? The ideal place for this to happen is the form validation framework, which does not exist in Flask.” Flask-WTF 1.2 fills that hole. FlaskForm already checks a token. Views that never use a form do not, until you mount CSRFProtect.
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect()
def attach_csrf(app):
csrf.init_app(app)
app.config.setdefault("WTF_CSRF_TIME_LIMIT", 3600)
return app
Call attach_csrf from the factory that already set SECRET_KEY. CSRF signs with that key unless you set WTF_CSRF_SECRET_KEY. Render the token on every form:
<form method="post" action="{{ url_for('account.email') }}">
{{ csrf_token() }}
<input type="email" name="email" value="{{ email }}">
<button type="submit">Save</button>
</form>
AJAX reads the same value from X-CSRFToken or X-CSRF-Token. Do not disable WTF_CSRF_ENABLED to make a test pass. Exempt one view with @csrf.exempt only when that view does not use a cookie session, for example a bearer-token webhook you already authenticate another way.
SameSite Lax is a belt. It is not a replacement for the token on login, on password reset, or on a browser that omits Fetch Metadata. The CSRF guide on this site is the longer argument. This page’s job is to mount the extension.
Jinja autoescape and the |safe hatch
Flask turns autoescape on for .html, .htm, .xml, .xhtml, and .svg when you call render_template. It turns it on for every string when you call render_template_string. That is the default that makes {{ bio }} print text.
The hatch has three names. Grep all three:
rg -n "\\|safe|Markup\\(|autoescape false|render_template_string" --glob '!venv' --glob '!.venv'
Markup in Python is the docs’ preferred way to mark a string trusted. Use it on HTML you built from an allowlisted converter, for example Markdown you ran through a sanitizer you chose. Do not wrap request.form["bio"]. |safe in the template is the same mark with a worse audit trail, because the view looks clean.
{% autoescape false %} turns the escape off for a block. That is a section-level hatch. Treat a hit like |safe.
render_template_string on a user-supplied template is SSTI, which is code execution. Autoescape will not save you. Do not render a string the user wrote. Keep templates on disk.
Pallets also warns that unquoted attributes are outside Jinja’s reach. Always quote:
<!-- BAD: unquoted attribute -->
<input value={{ name }}>
<!-- FIX -->
<input value="{{ name }}">
And href can still hold a javascript: URL after escaping. A tight CSP is the leftover control. Flask-Talisman can set it. This page will not pretend a filter fixes href.
SQLAlchemy binds, not f-strings
Flask-SQLAlchemy 3.1 runs queries through db.session.execute. The ORM path binds values. The hatch is a raw string you interpolate yourself.
from sqlalchemy import select, text
from.models import User
def user_by_email(db, email):
return db.session.execute(
select(User).filter_by(email=email)
).scalar_one_or_none()
def user_by_email_raw(db, email):
stmt = text("SELECT id FROM user_account WHERE email = :email")
return db.session.execute(stmt, {"email": email}).first()
user_by_email and user_by_email_raw both bind. The miss is an f-string or % format into text():
# BAD: do not ship
# db.session.execute(text(f"SELECT id FROM user_account WHERE email = '{email}'"))
Identifiers still cannot be bound. A sort token from the query string must map to a column you wrote, the same way the injection guide maps _sort. Do not concatenate a column name.
SORTS = {"created": User.created_at, "name": User.name}
def list_users(db, sort_token):
column = SORTS.get(sort_token, User.created_at)
rows = db.session.execute(select(User).order_by(column))
return rows.scalars()
SORTS is the allowlist. A missing key falls back to created_at, never to the raw token.
Debug stays off in production
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.
Do not set app.debug = True in code you deploy. Do not set it from a config object that a host can forget to override. The factory should refuse to boot if the environment name is production and debug is on:
def create_app():
app = Flask(__name__)
_load_config(app)
if os.environ.get("FLASK_ENV") == "production" and app.debug:
raise RuntimeError("debug is on in production")
if not app.config.get("SECRET_KEY"):
raise RuntimeError("SECRET_KEY unset")
return app
Serve with gunicorn or uwsgi, not app.run(). app.run is the development server. It is not a hardening control, but shipping it is how debug and the reloader leak into a container.
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.
app.config["TRUSTED_HOSTS"] = [".your-app.example", "your-app.example"]
Prove the key, the cookie, and the hatch
You are not walking an exploit. You are proving the process refuses a missing key, the Set-Cookie line carries the flags, and the template corpus has no |safe on request data.
# boot must fail without the key
env -u FLASK_SECRET_KEY flask --app myapp:create_app run
# Expect: RuntimeError missing FLASK_SECRET_KEY
# cookie flags on a login you already own
curl -sS -D - -o /dev/null -X POST "https://your-app.example/login" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data "email=you@your-app.example&password=REDACTED"
# Expect: Set-Cookie:... HttpOnly; Secure; SameSite=Lax
Grep the hatches this page named:
rg -n "secret_key\\s*=\\s*['\\\"]|SECRET_KEY\\s*=\\s*['\\\"]|\\|safe|Markup\\(|autoescape false|text\\(f[\\\"']|FLASK_DEBUG\\s*=\\s*1|app.debug\\s*=\\s*True" \
--glob '!venv' --glob '!.venv'
A hit on a literal key, on |safe, or on debug forced on is a review. A hit on Markup( needs a human to say where the HTML came from.
Questions we keep getting
Is a signed Flask session encrypted?
No. The client can read it. The signature stops modification if SECRET_KEY is random and unlisted. Store an id. Reload secrets on the server.
Does SameSite=Lax replace Flask-WTF?
No. Lax covers a foreign POST for modern browsers. Login CSRF, old clients, and same-site sibling hosts still need a token. Mount CSRFProtect.
When is |safe acceptable?
When the string is HTML your code produced and you can name the sanitizer. Never on request.args, request.form, or a database field you did not sanitize on write.



