Security essentials

Flask · Advanced · 14 min

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_passwd

Remember

  • 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.

Check yourself

  1. Which protection does Flask itself NOT provide out of the box?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: flask.palletsprojects.com/en/stable/web-security/

Builds on

Unlocks

Nothing builds on this yet.