Flask gives you a small, unopinionated core, which means most of the security decisions that larger frameworks make for you are left to your configuration and your choice of extensions. The default session is a signed cookie the user can read, CSRF protection is not built in, the session cookie is not marked Secure by default, and the interactive debugger will run arbitrary Python for anyone who reaches it. This guide covers the Flask settings, Werkzeug middleware and common extensions (Flask-WTF, Flask-Login) that decide whether a Flask application is safe in production. It is written for Python developers, application security engineers and reviewers who own or assess Flask services, and it goes deeper than the general language guidance in the Python Security Guide.
Key takeaways
- The SECRET_KEY is the application's master credential. Anyone who holds it can forge session cookies, so generate it with
secrets.token_hex(), keep it out of source control, and rotate it withSECRET_KEY_FALLBACKS(Flask 3.1 and later). - The default session is signed, not encrypted. Users can read everything you store in
session; never put secrets, tokens or authorisation data there that the user should not see. - Several cookie defaults are permissive.
SESSION_COOKIE_SECUREdefaults to False andSESSION_COOKIE_SAMESITEto None; set Secure, HttpOnly and SameSite=Lax explicitly, and do the same for Flask-Login's remember cookie. - CSRF protection is your job. Flask has no form framework, so register Flask-WTF's
CSRFProtectglobally rather than relying on individual forms. - Debug mode in production is remote code execution. The Werkzeug debugger executes Python from the browser and its PIN is not a security control; production must use a real WSGI server with debug off.
How Flask's security model differs from batteries-included frameworks
Django and Rails ship CSRF middleware, server-side sessions and security header defaults. Flask deliberately does not. Its own security documentation says it tries to solve a few problems by default and leaves the rest to the application. Knowing which side of that line each control sits on is the first step in any Flask review.
| Concern | What Flask does by default | What you must add |
|---|---|---|
| Cross-site scripting | Jinja autoescaping for templates ending in .html, .htm, .xml, .xhtml and .svg, and for render_template_string | Quote attributes, avoid Markup() and |safe on user data, add a Content Security Policy for javascript: URLs |
| CSRF | Nothing | Flask-WTF CSRFProtect plus SameSite cookies |
| Session storage | Client-side cookie signed with itsdangerous (HMAC, salt cookie-session) | Server-side session store if data must be confidential or revocable |
| Session cookie flags | HttpOnly on; Secure off; SameSite unset; lifetime 31 days when permanent | Secure, SameSite=Lax, a shorter PERMANENT_SESSION_LIFETIME |
| Security headers | None | HSTS, CSP, X-Content-Type-Options, X-Frame-Options (manually or via an extension such as Flask-Talisman) |
| Request size | MAX_FORM_MEMORY_SIZE 500 kB and MAX_FORM_PARTS 1,000 (Flask 3.1); MAX_CONTENT_LENGTH unset | Set MAX_CONTENT_LENGTH globally and per view |
| Host header | Any host accepted | TRUSTED_HOSTS (Flask 3.1) |
| Authentication | Nothing | Flask-Login or an OIDC library, plus password hashing |
The practical consequence is that two Flask applications built the same week can have completely different security postures depending on their create_app() factory. Reviews should start by reading the configuration object and the list of registered extensions, not the views.
SECRET_KEY: generation, storage and rotation
Flask uses SECRET_KEY to sign the session cookie, and extensions reuse it: Flask-WTF signs CSRF tokens with it unless WTF_CSRF_SECRET_KEY is set, and Flask-Login's remember cookie is protected by it. A leaked key lets an attacker mint a session cookie for any user ID, which in most applications means full account takeover, including administrator accounts. Treat it as a non-human credential with the same care as a database password.
- Generation: the Flask documentation recommends a long random value such as the output of
python -c 'import secrets; print(secrets.token_hex())'. Never use a word, the project name, or a value copied from a tutorial; values from public examples are trivially guessable and are among the first things an attacker tries. - Storage: load it at start-up from a secrets manager or an injected environment variable. Flask's
app.config.from_prefixed_env()reads variables prefixedFLASK_, soFLASK_SECRET_KEYbecomesSECRET_KEY. Keep it out ofconfig.py,.envfiles committed to Git, container images and Python packages. See the Secrets Management Guide. - Separation: use a different key per environment. A staging key reused in production means anyone with staging access can forge production sessions.
- Rotation: since Flask 3.1,
SECRET_KEY_FALLBACKSholds old keys that are still accepted for unsigning. Put the new key inSECRET_KEY, move the old one to the fallbacks list, and remove it after the longest session lifetime has passed. Flask tests the last fallback first, and extensions that useSECRET_KEYmay not support fallbacks yet, so test CSRF and remember-me after a rotation. - Emergency rotation: if the key has leaked, do not use fallbacks. Replace the key outright so every existing session and remember cookie becomes invalid, and accept that all users must log in again.
Hard-coded keys reach the public in the same ways as any other secret. The PyPI Secrets Exposure 2023 research found thousands of credentials in configuration files, test folders and READMEs shipped inside published Python packages, because packaging does not honour .gitignore by default. A Flask project that ships its instance/config.py inside a wheel has published its session signing key.
Sessions and cookies
Flask's default SecureCookieSessionInterface serialises the session dictionary, signs it with URLSafeTimedSerializer and sends it to the browser. The Flask quickstart is explicit that the user can see the contents but cannot modify them without the key. That design has three consequences: data in the session is not confidential, a session cannot be revoked on the server because the server keeps no record of it, and a stolen cookie stays valid until its signature ages past PERMANENT_SESSION_LIFETIME.
Recommended cookie configuration
| Setting | Default | Recommended | Why |
|---|---|---|---|
SESSION_COOKIE_SECURE | False | True | Stops the cookie travelling over plain HTTP (ASVS V3.3.1) |
SESSION_COOKIE_HTTPONLY | True | True | Keeps the cookie away from JavaScript and XSS payloads (ASVS V3.3.4) |
SESSION_COOKIE_SAMESITE | None (unset) | 'Lax', or 'Strict' for admin apps | Blocks the cookie on cross-site POSTs, a second CSRF layer (ASVS V3.3.2) |
SESSION_COOKIE_NAME | session | __Host-session where no subdomain sharing is needed | The prefix forces Secure, Path=/ and no Domain attribute (ASVS V3.3.3) |
SESSION_COOKIE_DOMAIN | None | Leave unset | Setting it shares the cookie with every subdomain |
PERMANENT_SESSION_LIFETIME | 31 days | Hours, not weeks, for sensitive apps | Flask rejects signatures older than this value, limiting replay |
REMEMBER_COOKIE_SECURE (Flask-Login) | False | True | The remember cookie is a long-lived login credential |
REMEMBER_COOKIE_DURATION (Flask-Login) | 365 days | 14 to 30 days | Shortens the window for a stolen remember cookie |
REMEMBER_COOKIE_SAMESITE (Flask-Login) | None | 'Lax' | Same reasoning as the session cookie |
Session handling rules
- Clear on login: call
session.clear()before writing the user ID at login, as the Flask security documentation shows, so no attacker-planted values survive authentication. This is the Flask form of the session fixation defence in ASVS V7.2.4. See the Session Fixation Guide. - Logout is not revocation: with client-side sessions, clearing the cookie on logout does not invalidate a copy an attacker already holds. If you need server-side revocation (forced logout, account suspension, "log out all devices"), either move to a server-side session store such as the Flask-Session extension backed by Redis or a database, or store a per-user session version number in the database and compare it on every request.
- Flask-Login session protection: Flask-Login defaults to
session_protection = "basic", which hashes the IP address and user agent and marks the session non-fresh when they change."strong"deletes the session instead. Usefresh_login_requiredon password change, MFA enrolment and payment views so a stolen cookie cannot reach them. Strong mode can log out users on mobile networks whose IP address changes often, so test it before enforcing. - Caching proxies: CVE-2023-30861 showed that a Flask response refreshing a permanent session could be cached by a proxy and served, with its
Set-Cookie, to another user, because Flask setVary: Cookieonly when the session was accessed or modified. It is fixed in Flask 2.2.5 and 2.3.2. Beyond upgrading, mark authenticated responsesCache-Control: privateorno-storeand configure CDNs not to cache responses that carrySet-Cookie.
For the wider theft and replay picture, including cookie binding, see the Session Hijacking and Cookie Theft Guide.
CSRF, templates and security headers
CSRF with Flask-WTF
Any view that processes a FlaskForm gets CSRF validation, but views that read request.form or JSON directly do not. Register CSRFProtect(app) (or csrf.init_app(app) in an application factory) so every POST, PUT, PATCH and DELETE is checked, the default WTF_CSRF_METHODS. Key settings:
- Forms without FlaskForm: render
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">. - JavaScript clients: expose the token with
{{ csrf_meta_tag() }}and send it in theX-CSRFTokenorX-CSRF-Tokenheader (the defaultWTF_CSRF_HEADERS). - Referrer check: keep
WTF_CSRF_SSL_STRICTTrue (the default), which checks that the referrer matches the host on HTTPS requests. - Token lifetime:
WTF_CSRF_TIME_LIMITdefaults to 3,600 seconds; keep page cache lifetimes below it to avoid spurious failures. - Exemptions:
@csrf.exemptis legitimate only for endpoints authenticated by something other than cookies, such as webhook receivers that verify an HMAC signature or APIs that require a bearer token. Review every exemption.
See the CSRF Prevention Guide for the underlying patterns.
Templates
Autoescaping covers HTML element content, but Flask's documentation lists the gaps: HTML generated outside Jinja, Markup() called on user input, unquoted attributes such as <input value={{ value }}>, and user-controlled href values that can carry a javascript: URL. Two further Flask-specific risks deserve a code search: render_template_string() called with a string built from user input, which turns template syntax into server-side template injection and can lead to code execution (ATT&CK T1059.006, Python), and templates named with extensions outside the autoescape list, such as .txt or .j2, which are rendered without escaping. See the Cross-Site Scripting (XSS) Prevention Guide.
Security headers
Flask sets none. Add them in an after_request handler, at the reverse proxy, or with an extension such as Flask-Talisman, which the Flask documentation mentions. The values below are the starting points the Flask documentation gives.
| Header | Starting value | Purpose |
|---|---|---|
| Strict-Transport-Security | max-age=31536000; includeSubDomains | Forces HTTPS for a year |
| Content-Security-Policy | default-src 'self', then refine | Limits script sources and blocks javascript: URLs |
| X-Content-Type-Options | nosniff | Stops MIME sniffing of uploaded content |
| X-Frame-Options | SAMEORIGIN (or CSP frame-ancestors) | Clickjacking defence |
See the Content Security Policy Guide and the Clickjacking Guide.
Debug mode, error handling and production serving
The Flask and Werkzeug documentation could not be clearer: never run the development server or the debugger in production, where "production" means anything that is not local development and anything publicly reachable. The debugger's console executes arbitrary Python in the server process. Its PIN is described by Werkzeug as a helper that makes exploitation harder, not a security control, and it can be disabled entirely with WERKZEUG_DEBUG_PIN=off. An exposed debugger is an exploitable public-facing application (ATT&CK T1190) that hands over the process, its environment variables and therefore every secret the app holds.
- Serve with a WSGI server: run under Gunicorn, Waitress, mod_wsgi or uWSGI behind a reverse proxy such as nginx, never
flask runorapp.run(). - Guard debug in code: fail start-up if
app.debugis True and the environment is not development. Search fordebug=True,--debugandDebuggedApplicationin the codebase, Dockerfiles and process manager configs. - Error handlers: register
@app.errorhandler(500)and@app.errorhandler(Exception)that return a generic page or JSON body with a correlation ID, and log the traceback server-side (ASVS V16.5.1). LeavePROPAGATE_EXCEPTIONSandTRAP_BAD_REQUEST_ERRORSat their production defaults. - Logging hygiene: never log request bodies,
Authorizationheaders or the session cookie. See the Secure Error Handling and Logging Guide.
Proxies, hosts and request limits
Most Flask deployments sit behind at least one proxy or load balancer, and this layer is where several quiet vulnerabilities live.
- ProxyFix counts: Werkzeug's
ProxyFixrewritesREMOTE_ADDR, the URL scheme and the host fromX-Forwarded-For,-Proto,-Host,-Portand-Prefix. Setx_for,x_protoandx_hostto the exact number of proxies that set each header, and only use it when there really is a proxy. Its own documentation warns that trusting values the client supplied is a security issue: an over-trusting ProxyFix lets anyone spoof their IP address past rate limits and audit logs (ASVS V4.1.3). See the Proxies and Forward vs Reverse Proxy Security Guide. - Trusted hosts: set
TRUSTED_HOSTS(Flask 3.1) to your real host names, for example["app.example.com", ".example.com"]. Without it,url_for(..., _external=True)builds links from whateverHostheader arrives, which is how password reset emails end up pointing at attacker domains. Invalid hosts raise aSecurityErrorthat returns 400. - Request size: set
MAX_CONTENT_LENGTH, which is unset by default, and lower it per view throughrequest.max_content_length. Keep the Flask 3.1 defaults forMAX_FORM_MEMORY_SIZE(500 kB per field) andMAX_FORM_PARTS(1,000). Mirror limits and timeouts at the WSGI server and proxy (for example nginxclient_max_body_sizeand Gunicorn--timeout). - File handling: pass client file names through
werkzeug.utils.secure_filename(), store uploads outside the static folder, serve them withsend_from_directory(..., as_attachment=True)rather than building paths with string concatenation, and never serve uploaded HTML inline. See the Secure File Upload Handling Guide. - Redirects: validate any
nextparameter after login against a relative path or an allow list before callingredirect(), since Flask-Login's examples leave this check to the developer.
Choosing and vetting extensions
A Flask application's attack surface is largely the sum of its extensions. Each one runs with the application's full privileges and often reads SECRET_KEY.
| Need | Common choices | Security settings to check |
|---|---|---|
| CSRF and forms | Flask-WTF | CSRFProtect registered globally, WTF_CSRF_ENABLED not disabled outside tests, exemptions reviewed |
| Login sessions | Flask-Login | Remember cookie flags and duration, session_protection, fresh_login_required on sensitive views, safe next handling |
| Server-side sessions | Flask-Session | Backend authentication (Redis ACLs, TLS), session ID regeneration, expiry in the store |
| Headers and HTTPS | Flask-Talisman or proxy configuration | CSP policy content, HSTS max-age, no conflicting headers from the proxy |
| Rate limiting | Flask-Limiter | Shared storage across workers, key function uses the ProxyFix-corrected address |
| CORS | Flask-CORS | No wildcard origin with credentials; explicit allow list |
| Database | Flask-SQLAlchemy | ORM queries or bound parameters only; no text() with string formatting |
Decision guidance. A small internal tool with a handful of users can live with client-side sessions, Flask-WTF and headers set at the proxy. A customer-facing or regulated application should move to server-side sessions for revocation, enforce fresh login on sensitive actions, and run CSP in report-only mode first. A pure JSON API consumed by non-browser clients should not use cookie sessions at all: authenticate with OAuth access tokens validated per request, and CSRF then largely falls away. See the API Security Guide. When choosing an extension, prefer actively maintained projects (recent releases, security advisories handled), pin versions with hashes, and scan dependencies with pip-audit in CI.
Implementation roadmap
- Inventory: list every Flask app, its Flask and Werkzeug versions, registered extensions, how
SECRET_KEYis loaded, and how it is served (WSGI server, proxy chain). - Remove critical exposures first: disable debug mode anywhere it is reachable, replace hard-coded or tutorial
SECRET_KEYvalues (outright, not via fallbacks), and upgrade past known advisories such as CVE-2023-30861. - Harden configuration: set the cookie flags, lifetimes,
TRUSTED_HOSTS,MAX_CONTENT_LENGTHand correct ProxyFix counts in a shared base config used by every app. - Add CSRF and headers: register
CSRFProtect, then deploy CSP inContent-Security-Policy-Report-Onlymode, collect reports for two to four weeks, and enforce. - Test: add integration tests that assert cookie attributes, the 400 response for an unknown host, the 400 response for a missing CSRF token, and that error responses contain no traceback. Run a DAST scan against staging.
- Roll out and roll back: deploy to a canary instance first. Cookie changes (name, SameSite, Domain) log users out or break embedded flows, so keep the previous config as a feature flag and revert it if login or payment error rates rise. Never roll back debug mode or a leaked key.
- Automate: add Bandit (which flags
debug=True), secret scanning on commits and built wheels, andpip-auditto the pipeline. See the Secure Software Development Lifecycle (SSDLC) Guide.
Detection and measurement
- What to log: logins, logouts, failed logins, CSRF failures (Flask-WTF logs them at INFO to the
flask_wtf.csrflogger),SecurityErrorhost rejections, 413 responses, and session signature failures. - What to alert on: requests for
/consoleor Werkzeug debugger resources, spikes in invalid session signatures (key guessing or a botched rotation), and a burst of CSRF failures from one origin.
Metrics that show the programme is working:
- Percentage of Flask apps with debug provably off in production (target 100%).
- Percentage of apps loading
SECRET_KEYfrom a secrets manager, and days since last rotation. - Percentage of apps passing an automated cookie flag and header check.
- Number of
@csrf.exemptroutes without a documented alternative authentication method (target zero). - Mean time to upgrade Flask, Werkzeug and extensions after a security release.
Framework mapping
| Control area | NIST SP 800-53 Rev. 5 | NIST CSF 2.0 | OWASP ASVS 5.0 | ISO/IEC 27001:2022 Annex A |
|---|---|---|---|---|
| Secret key management | IA-5, SC-12 | PR.AA-03 | V13.3.1 | A.8.24 |
| Session and cookie security | SC-23 | PR.DS-02 | V3.3.1, V3.3.2, V3.3.4, V7.2.4 | A.8.26 |
| Debug off, least functionality | CM-7 | PR.PS-01 | V13.4.2 | A.8.9 |
| Input and request limits | SI-10, SC-5 | PR.PS-06 | V4.1.3 | A.8.28 |
| Error handling and logging | SI-11, AU-2 | PR.PS-04 | V16.5.1 | A.8.15 |
| Dependency and extension risk | RA-5, SA-11 | ID.RA-01 | V13.4.6 | A.8.25 |
CIS Controls v8.1 Control 16 (Application Software Security) covers the development practices in this guide.
Common failure patterns
- Tutorial secret keys in production:
SECRET_KEY = "dev"or a value copied from documentation, committed to Git or shipped in a package. As the PyPI Secrets Exposure 2023 research showed, configuration files leak through published packages long after the source repository is cleaned. - Debug left on:
app.run(debug=True)at the bottom ofapp.py, then the same file used as the container entry point. - Sensitive data in the session: roles, API tokens or personal data stored in a cookie the user can decode with a base64 tool.
- Selective CSRF: FlaskForm used on some pages while AJAX endpoints and raw
request.formhandlers have no token check. - Blind proxy trust:
ProxyFix(app, x_for=2)behind a single proxy, letting clients choose their own IP address for rate limiting and logs. - Assuming logout revokes: responding to a stolen account by asking the user to log out, while the attacker's copy of the signed cookie stays valid for 31 days.
Practitioner checklist
- Generate
SECRET_KEYwithsecrets.token_hex(), load it from a secrets manager, use one per environment, and plan rotation withSECRET_KEY_FALLBACKS. - Set
SESSION_COOKIE_SECURE=True,SESSION_COOKIE_SAMESITE='Lax', keep HttpOnly, and shortenPERMANENT_SESSION_LIFETIME. - Apply the same flags to Flask-Login's remember cookie and cut
REMEMBER_COOKIE_DURATIONfrom 365 days. - Call
session.clear()at login and use server-side sessions or a session version where revocation matters. - Register
CSRFProtectglobally and review every@csrf.exempt. - Prove debug mode is off in production and serve through a WSGI server behind a reverse proxy.
- Set
TRUSTED_HOSTS,MAX_CONTENT_LENGTHand exact ProxyFix counts. - Add HSTS, CSP, nosniff and frame protection, starting CSP in report-only mode.
- Search for
render_template_string,Markup(and|safeon user data. - Pin Flask, Werkzeug and extensions, and run
pip-audit, Bandit and secret scanning in CI.
Standards and references
- Pallets: Flask Security Considerations
- Pallets: Flask Configuration Handling
- Pallets: Werkzeug Debugging Applications
- Flask-WTF: CSRF Protection
- Flask-Login: Documentation
- GitHub Security Advisory: Flask CVE-2023-30861 possible disclosure of permanent session cookie
- OWASP: Application Security Verification Standard 5.0
- NIST: SP 800-53 Rev. 5
Related NHI Mgmt Group resources: Python Security Guide · Secrets Management Guide · Token and Session Security Guide · CSRF Prevention Guide · gRPC Security Guide