Flask is minimal by design, so several protections that other frameworks enable by default are your job.
What Flask gives you, and what you must add:
XSS (cross-site scripting). Jinja autoescapes .html templates. The holes are |safe, Markup(...) on user input, building HTML strings in Python, unquoted attributes and user data inside <script>. For data passed to JavaScript, use the |tojson filter.
CSRF. Flask has no CSRF protection. Any form or cookie-authenticated API that changes state needs tokens, usually via Flask-WTF's CSRFProtect(app), plus SameSite cookies as a second layer.
Sessions and cookies. Use a long random SECRET_KEY and set:
SESSION_COOKIE_SECURE=True, SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax"Resource limits. Set MAX_CONTENT_LENGTH to cap request bodies; larger requests get 413. Flask 3.1 also has MAX_FORM_MEMORY_SIZE and MAX_FORM_PARTS for multipart forms.
Host header. Set TRUSTED_HOSTS (Flask 3.1) or validate at the proxy, so attackers can't poison links generated with _external=True, such as password reset emails.
Security headers. Add HSTS, X-Content-Type-Options: nosniff, X-Frame-Options or a Content-Security-Policy, either in after_request, at the proxy, or with an extension such as Flask-Talisman.
Passwords. Hash with werkzeug.security.generate_password_hash (scrypt by default) and verify with check_password_hash; never store or log plain passwords.
Files. Always run user-supplied filenames through secure_filename and serve user files with send_from_directory.
Finally, never deploy with debug mode, and keep dependencies updated.
Example
from flask import Flask, request
from werkzeug.security import check_password_hash, generate_password_hash
from werkzeug.utils import secure_filename
app = Flask(__name__)
app.config.update(
SECRET_KEY="load-me-from-the-environment",
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_SAMESITE="Lax",
MAX_CONTENT_LENGTH=1024, # bytes
)
@app.after_request
def security_headers(resp):
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["X-Frame-Options"] = "DENY"
return resp
@app.post("/upload")
def upload():
return {"bytes": len(request.get_data())}
print(app.test_client().post("/upload", data="x" * 5000).status_code) # 413
h = generate_password_hash("hunter2")
print(h.split(":")[0], check_password_hash(h, "hunter2")) # scrypt True
print(secure_filename("../../etc/passwd")) # etc_passwdRemember
- Autoescaping covers XSS only if you never bypass it with |safe on user data
- Flask has no CSRF protection: add Flask-WTF CSRFProtect
- Secure cookie flags, MAX_CONTENT_LENGTH and hashed passwords are must-haves
Watch out for
- Assuming Flask protects forms from CSRF by default.