Security for Python web apps: the failures that actually show up in audits and incident reports. This is not a penetration testing course. It is the baseline – secrets, sessions, headers, input handling, and rate limiting – before you expose a Flask app to the internet.
Secrets and configuration
Never commit API keys, database passwords, or Flask SECRET_KEY values to Git. Load them from environment variables or a secret manager. Rotate keys when someone leaves the team or a laptop goes missing.
import os
from flask import Flask
def create_app() -> Flask:
app = Flask(__name__)
secret = os.environ.get("SECRET_KEY")
if not secret:
raise RuntimeError("SECRET_KEY environment variable is required")
app.config["SECRET_KEY"] = secret
return app
Authentication and sessions
Use established libraries – Flask-Login, Authlib, or your platform’s identity service – rather than home-grown password hashing. Store passwords with a slow hash (bcrypt, argon2). Mark session cookies HttpOnly, Secure in production, and SameSite appropriately.
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SECURE=True, # HTTPS only in production
SESSION_COOKIE_SAMESITE="Lax",
)
CSRF for cookie-based forms
If the browser sends session cookies automatically, state-changing POST requests need CSRF protection. Flask-WTF and similar extensions generate tokens. Pure JSON APIs that use bearer tokens in headers often skip CSRF – but then you must not rely on cookies alone for auth on those endpoints.
XSS and output encoding
Jinja2 auto-escapes HTML in templates by default. Keep it that way. Do not mark untrusted user input as safe with |safe unless you have sanitised it properly. JSON responses are not an XSS vector by themselves, but embedding JSON inside HTML pages without escaping is.
Input validation everywhere
Treat all request data as hostile: query strings, JSON bodies, headers used in logic, uploaded filenames. Validate types, lengths, and allowed characters. Reject early with 400 responses.
import re
from flask import abort, request
SLUG_RE = re.compile(r"^[a-z0-9-]{1,64}$")
@app.get("/api/items/<slug>")
def get_item(slug: str):
if not SLUG_RE.match(slug):
abort(400, description="invalid slug")
...
Security headers
Set baseline headers in Flask or at the reverse proxy (nginx, Caddy). Talisman can help in Flask apps.
# Example headers to aim for in production:
# Content-Security-Policy (start restrictive, loosen as needed)
# X-Content-Type-Options: nosniff
# X-Frame-Options: DENY or SAMEORIGIN
# Referrer-Policy: strict-origin-when-cross-origin
# Strict-Transport-Security (HTTPS only)
Rate limiting and abuse
Login endpoints, password reset, and expensive API routes attract bots. Limit requests per IP or per account. Flask-Limiter is a common choice. Combine with captcha or proof-of-work only when the abuse cost justifies the user friction.
Dependencies and updates
Run pip audit or use Dependabot. Pin dependencies in applications. Old Flask, Requests, or Pillow versions have had real CVEs. Updating beats writing custom WAF rules for known bugs.
Least privilege
Run the app as a non-root user. Give file and cloud permissions the minimum needed. Separate read and write roles for admin tools. When something gets compromised, narrow blast radius buys time.

