Safeguard
Application Security

A hardening guide to securing Flask applications

Flask ships session cookies with HttpOnly on by default — but Secure, SameSite, CSRF tokens, and every security header are left entirely to you.

Safeguard Research Team
Research
6 min read

Flask is a microframework by design, and that design choice has a security consequence worth stating plainly: out of the box, Flask signs and HttpOnly-protects your session cookie, but it does not set the Secure flag, does not enforce SameSite, does not ship CSRF protection for your forms, and does not add a single security response header. Flask's own security documentation is explicit that these are the application developer's responsibility, not the framework's. That gap matters because Flask has been one of the most widely deployed Python web frameworks for over a decade, powering everything from internal admin panels to production APIs, and a default Flask(__name__) app deployed as-is is missing at least four categories of hardening a security review will flag on day one: transport-bound cookies, cross-site cookie policy, CSRF tokens on state-changing routes, and headers like Content-Security-Policy and Strict-Transport-Security. None of these gaps require exotic tooling to close — Flask-WTF's CSRFProtect and a handful of SESSION_COOKIE_* config keys cover most of it, with Flask-Talisman or a manual after_request hook covering the rest. This guide walks through each dimension using Flask's official recommendations as the baseline, then covers what's still missed even after you apply them.

What does Flask actually secure by default, and what's left to you?

Flask's session implementation signs its cookie using SECRET_KEY so the client can't forge or tamper with session contents undetected, and SESSION_COOKIE_HTTPONLY defaults to True, which blocks JavaScript from reading the cookie via document.cookie — a direct mitigation against cookie theft through XSS. That's the extent of the defaults. SESSION_COOKIE_SECURE, which stops the browser from ever sending the cookie over plain HTTP, defaults to False because Flask has no way to know at import time whether your deployment terminates TLS. SESSION_COOKIE_SAMESITE is unset by default too, leaving cookie behavior on cross-site requests to the browser's own default policy rather than an explicit application decision. Flask's security docs are direct about the implication: a signed, HttpOnly cookie is still a cookie that a network attacker on an unencrypted connection can capture, or that a cross-site form can silently ride along with, unless you set both values yourself in app.config.

How do you configure session cookies correctly?

Flask's documented baseline is three config keys set together: SESSION_COOKIE_SECURE = True, SESSION_COOKIE_HTTPONLY = True (already the default, but worth setting explicitly for clarity), and SESSION_COOKIE_SAMESITE = 'Lax' or 'Strict'. 'Lax' is the pragmatic default for most apps — it still sends the cookie on top-level navigation (a user clicking a link into your app) but withholds it on cross-site subrequests and form posts, which blocks the classic CSRF pattern where a malicious page auto-submits a form to your endpoint. 'Strict' withholds the cookie even on top-level navigation from another site, which is more restrictive and can break flows like clicking an emailed link into an authenticated page. Alongside those, PERMANENT_SESSION_LIFETIME bounds how long a session.permanent = True cookie stays valid — Flask's default is 31 days, which is long enough that a stolen session token from an unencrypted network capture stays useful for weeks unless you shorten it explicitly for anything handling sensitive data.

Why doesn't Flask ship CSRF protection, and how do you add it?

Flask deliberately stays a microframework and leaves CSRF protection to extensions, because not every Flask app is a server-rendered form-based site — plenty are pure JSON APIs where token-based CSRF doesn't apply the same way. For traditional form-based apps, Flask's documentation points to Flask-WTF's CSRFProtect, which you initialize once with CSRFProtect(app) and it then validates a hidden csrf_token field on every POST, PUT, PATCH, and DELETE request by default, rejecting the request with a 400 if the token is missing or doesn't match the one issued in the session. Every WTForms-based form gets the token automatically via {{ form.csrf_token }}; for a plain HTML form you add it manually with {{ csrf_token() }}. The mechanism is the synchronizer token pattern: a per-session secret is embedded in the form and Flask-WTF checks that the submitted value matches, which a cross-site attacker forging a request from another origin cannot know or predict. This is a case where SameSite cookies and CSRF tokens are complementary, not redundant — SameSite is a browser-level backstop, CSRFProtect is an application-level guarantee that doesn't depend on every user's browser correctly implementing SameSite.

Which response headers does Flask leave out, and how do you add them?

Flask sets no security-related response headers by default — no X-Content-Type-Options, no Content-Security-Policy, no Strict-Transport-Security, no X-Frame-Options. Flask's docs recommend adding these either through Flask-Talisman, an extension purpose-built for this, or manually via an @app.after_request hook. At minimum, X-Content-Type-Options: nosniff stops browsers from MIME-sniffing a response into an unintended content type, Strict-Transport-Security (HSTS) tells browsers to only ever connect over HTTPS for a specified duration once they've seen it once, and a Content-Security-Policy restricts which script, style, and resource origins the page will execute or load, materially reducing the blast radius of a successful XSS injection even if one slips past input handling. Flask-Talisman applies a reasonable set of these by default and also forces HTTPS redirects, which is useful because it centralizes a policy that's otherwise easy to get partially right by hand-writing headers on some routes and forgetting others.

What does hardening the config alone still miss?

Cookie flags, CSRF tokens, and headers close the categories Flask's docs enumerate, but they don't touch the vulnerability classes that live inside your view functions: a raw SQL string built with an f-string instead of a parameterized query, a subprocess.run() call built from a request parameter, or a render_template_string() call fed unescaped user input (Flask's Jinja2 autoescaping protects render_template() with .html files, but string-templating helpers bypass it). Configuration hardening is necessary and it's also the easy, one-time part; the vulnerability classes that show up in individual routes require actually tracing how untrusted input moves through your code, route by route, which is a different kind of analysis than checking whether SESSION_COOKIE_SECURE is set.

How Safeguard helps

Safeguard's SAST engine covers Python as one of its phase-1 supported languages, tracing untrusted input from a source — a Flask request.args, request.form, or request.json access — through your code to a dangerous sink like a raw SQL query or a shell call, with the full dataflow trace attached to each finding so a developer can see exactly why a given line was flagged rather than getting a bare "possible injection" warning. That closes the gap config hardening can't: a correctly configured SESSION_COOKIE_SAMESITE doesn't tell you whether a view function three files away builds a query with string concatenation. On the deployed side, Safeguard's DAST engine tests a running application for exactly the class of misconfiguration this guide covers — missing security headers among them — by sending safe, non-destructive requests against a verified, in-scope target and observing the responses, so a Flask app that forgot its after_request hook on one blueprint gets caught in the same scan that's checking for injection classes elsewhere in the app.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.