This guide is a concise, code-first hardening checklist to lock down Flask.
TL;DR checklist
- Secrets in env/secret store (never in git)
- HTTPS everywhere + HSTS (1y+)
- Security headers (CSP, X-Frame-Options, Referrer-Policy, etc.)
- Strict cookies and sessions (Secure, HttpOnly, SameSite)
- CSRF protection on state-changing routes
- Authentication with strong hashing (argon2/bcrypt) and MFA where possible
- Rate limiting on auth and sensitive endpoints
- Input validation and safe file uploads
- Dependency pinning and security scanning (pip-audit, bandit)
- Proper reverse proxy + WSGI (gunicorn/uwsgi) and non-root containers
- Structured audit logs + alerting
1) Configuration and secrets
# app/config.py
import os
class Settings:
secret_key = os.environ["FLASK_SECRET_KEY"]
session_cookie_secure = True
session_cookie_httponly = True
session_cookie_samesite = "Lax" # or "Strict" for same-site apps
remember_cookie_duration = 0
settings = Settings()
Use a secrets manager (AWS Secrets Manager, GCP Secret Manager, 1Password Connect) or at least .env (development only). Never commit secrets.
# .env (dev only)
FLASK_SECRET_KEY='change-me'
DATABASE_URL='postgresql://user:pass@localhost:5432/app'
2) HTTPS + HSTS (app-level)
flask-talisman is a lightweight Flask extension that sets sane HTTP security
headers for you and can force HTTPS/HSTS. Why this matters:
- Prevents protocol downgrade and mixed‑content issues (HSTS)
- Reduces clickjacking and information leakage (X‑Frame‑Options, Referrer‑Policy)
- Gives you a configurable CSP baseline to curb XSS
Notes:
-
Keep HTTPS forcing off in local dev or set
app.debug = True. -
Behind a proxy, ensure
X-Forwarded-Protois set so Talisman detects HTTPS. -
PyPI: Flask-Talisman
-
Install:
pip install flask-talisman
# app/security.py
from flask import Flask
from flask_talisman import Talisman
csp = {
'default-src': ["'self'"],
'script-src': ["'self'", "'unsafe-inline'"], # switch to nonces below for stricter CSP
'style-src': ["'self'", "'unsafe-inline'"],
'img-src': ["'self'", 'data:'],
}
def apply_security(app: Flask) -> None:
Talisman(
app,
content_security_policy=csp,
force_https=True,
strict_transport_security=True,
strict_transport_security_max_age=31536000,
frame_options='SAMEORIGIN',
referrer_policy='no-referrer',
session_cookie_secure=True,
session_cookie_http_only=True,
)
3) Sessions and cookies
Prefer server-side sessions (e.g., Redis) over client-side signed cookies for sensitive apps.
Why:
- Server-side sessions let you invalidate sessions instantly (logouts, revocations)
- You avoid large, tamper‑resistant but still replayable client cookies
- Easier to store additional auth context securely (roles, anti‑replay tokens)
If you must use cookie sessions, keep cookies short‑lived, set Secure+HttpOnly+SameSite, and rotate keys.
Library: Flask-Session
Install:
pip install Flask-Session
from flask import Flask
from flask_session import Session
def configure_sessions(app: Flask) -> None:
app.config.update(
SESSION_TYPE='redis',
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE='Lax',
PERMANENT_SESSION_LIFETIME=0,
)
Session(app)
4) CSRF protection
Library: Flask-WTF
Install:
pip install flask-wtf
Why:
- CSRF exploits the browser’s cookies; tokens bind a user action to a form/page
- SameSite=Lax helps but is not sufficient for all flows (e.g., some OAuth/POST)
from flask_wtf import CSRFProtect
csrf = CSRFProtect()
def enable_csrf(app):
app.config['WTF_CSRF_TIME_LIMIT'] = None
csrf.init_app(app)
5) Authentication, hashing, and rate limiting
Libraries:
- Hashing:
werkzeug.security(built‑in PBKDF2/Scrypt). For Argon2, use argon2-cffi - Rate limiting: Flask-Limiter
Install:
pip install Flask-Limiter
from werkzeug.security import generate_password_hash, check_password_hash
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(key_func=get_remote_address, default_limits=["200/min"])
def hash_password(pw: str) -> str:
return generate_password_hash(pw, method='scrypt') # or 'pbkdf2:sha256', 'bcrypt', 'argon2'
def verify_password(hash_: str, pw: str) -> bool:
return check_password_hash(hash_, pw)
def protect_auth(app):
limiter.init_app(app)
@app.post('/login')
@limiter.limit("5/minute")
def login():
...
Why:
- Slow down credential‑stuffing and brute‑force attempts (Limiter)
- Store only password hashes; never plaintext. Prefer memory‑hard KDFs
Optional (Argon2):
pip install argon2-cffi
from argon2 import PasswordHasher
ph = PasswordHasher()
hash_ = ph.hash(password)
ph.verify(hash_, candidate)
6) Input validation & uploads
Use pydantic/marshmallow to reject bad input early. For files: verify MIME by content (magic), enforce size/extension allow‑lists, and randomize filenames.
Libraries:
- pydantic or marshmallow
- pydantic: fast validation, type‑driven models
- marshmallow: explicit schemas/serialization control
Install one:
pip install pydantic
# or
pip install marshmallow
from pydantic import BaseModel, EmailStr, constr
class RegisterBody(BaseModel):
email: EmailStr
password: constr(min_length=12)
7) Content Security Policy (nonce pattern)
Why:
- A strict CSP with nonces blocks inline/script injection and most reflected XSS
- Nonces work better than hashes when content changes per request
from flask import g
import secrets
@app.before_request
def set_nonce():
g.csp_nonce = secrets.token_urlsafe(16)
# Jinja: <script nonce="{{ g.csp_nonce }}">...</script>
Update your CSP to include script-src 'self' 'nonce-{value}' and issue a fresh nonce per request; replace any inline event handlers with proper scripts.
8) Dependency hygiene (code-focused)
pip install --upgrade pip pip-tools pip-audit bandit
pip-compile -o requirements.txt pyproject.toml # or pin with pip-tools
pip-audit
bandit -r app/
9) Observability and logs
Emit JSON logs and forward to your SIEM. Consider Sentry or OpenTelemetry for traces.
import json, logging, sys
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter('%(message)s'))
logger = logging.getLogger('app')
logger.setLevel(logging.INFO)
logger.addHandler(handler)
def audit(event: str, **kwargs):
logger.info(json.dumps({'event': event, **kwargs}))
10) Safe defaults you should set
app.config.update(
JSONIFY_PRETTYPRINT_REGULAR=False,
PROPAGATE_EXCEPTIONS=False,
MAX_CONTENT_LENGTH=10 * 1024 * 1024, # 10 MB uploads
SEND_FILE_MAX_AGE_DEFAULT=31536000,
)
11) Bonus hardening snippets
Add request size limits, disable server error leaks, and sanitize headers.
from flask import Flask, request
def extra_hardening(app: Flask):
app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024
@app.after_request
def remove_server_header(resp):
resp.headers.pop('Server', None)
return resp
@app.before_request
def block_weird_methods():
if request.method not in { 'GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS' }:
return ("", 405)
Final checklist before go-live
- TLS + HSTS enabled and verified
- CSP enforced (report-only first, then enforce)
- Cookies: Secure+HttpOnly+SameSite, session lifetime minimal
- CSRF on all unsafe methods
- Rate limits on auth/sensitive routes
- Pinned deps,
pip-auditclean,banditreviewed - Logs structured and shipped; alerts configured