Putting it together: the security of a real website, from Cloudflare to the .env file

A threat model for viet.sarl, a walk through every layer (Cloudflare, TLS, Caddy, cookies, tokens, Argon2id, OAuth, admin permissions, SQLite, SSH, secrets), common web vulnerabilities and how this site blocks them, what's still missing, and a checklist.

The eight previous posts were separate tools: random numbers, hash functions, passwords, public keys, TLS, encryption at rest, OAuth, SSH. The last post puts them together on one real system: viet.sarl itself. The goal isn't to show off that the site is secure, but to show how to think about a system's security: start from "who are we defending against", walk through every layer, then honestly write down what's still missing.

Roadmap: the threat model, a diagram of the layers, a walk through each layer, common web vulnerabilities (the OWASP Top 10) checked against this site, the supply chain, what's still missing, then the end-of-series checklist. Everything about the site comes from the code in the repo and from read-only commands run against the public site. The post prints no server address, secret or internal configuration.

The threat model

A threat model answers four questions: what's worth protecting, who wants it, where they come in, and how much losing it costs. For a blog with a few leaderboard games:

Asset Why it matters
Emails and password hashes People reuse passwords elsewhere (post 3)
Sign-in sessions, admin rights Account takeover; admins can lock users and edit leaderboards
Secrets: JWT_SECRET, the Google OAuth keys, the SMTP password With JWT_SECRET, anyone can sign a session for anyone
The server and SSH access Whoever has the server has everything above
Availability, reputation The site goes down or gets strange content injected

The realistic attacker for a site this size isn't a nation state. It's automated bots scanning millions of sites for common mistakes, such as an exposed .env file, weak passwords or outdated software; people cheating on leaderboards; someone guessing passwords from leaked lists; and yourself, accidentally pushing a secret to git. This model decides what to do first: blocking common mistakes matters more than resisting quantum algorithms. As it happens, Cloudflare already takes care of the quantum part.

The site's layers

Browser
   │  TLS 1.3, X25519MLKEM768 (posts 4, 5)
Cloudflare      ── caching, denial-of-service protection, hides the origin address
   │  TLS, Caddy's Let's Encrypt certificate
Caddy           ── HTTPS, www redirect, load balancing across containers during deploys
   │  HTTP inside the Docker network
FastAPI app     ── cookies, tokens, Argon2id, OAuth + PKCE, permission checks, HTML sanitising
   │
SQLite          ── emails, password hashes, Google links, scores
Server (VPS)    ── SSH with keys, .env.production

Walking through each layer

Cloudflare. It stands in front of every request, so the server's real IP address doesn't appear in public DNS. If that address leaks, say through an email header or an old DNS record, an attacker could go around Cloudflare. The complete measure is a server firewall that only accepts ports 80 and 443 from Cloudflare's IP ranges. It's also why no post on this site ever prints the server's address.

TLS. Post 5 read it in detail: TLS 1.3, AES-256-GCM, post-quantum hybrid key exchange, 90-day certificates renewed automatically. HTTP is redirected with a 308 to HTTPS. HSTS is still missing (last section).

Caddy. Only ports 80 and 443 are open to the outside. The app only exposes port 8000 inside the Docker network, with no ports, so nobody on the Internet can call the app directly and skip Caddy.

Cookies. Every sign-in cookie is set by the same function:

response.set_cookie(
    key, value, max_age=max_age, path=path, httponly=True, secure=settings.ENVIRONMENT.is_deployed, samesite="lax"
)
  • HttpOnly: JavaScript can't read the cookie, so even with an XSS bug, an attacker can't take the session elsewhere.
  • Secure: only sent over HTTPS.
  • SameSite=Lax: other sites can't submit POST forms with this cookie, which blocks most CSRF.
  • The refresh token is only sent to /auth (path), so it doesn't ride along with thousands of ordinary requests.

Tokens. Sessions use HMAC-signed tokens, with a 15-minute access token and a 30-day refresh token. Each token has a typ claim, and a token of one type is never accepted as another. The library is called with a fixed list of algorithms (algorithms=[...]), and the app refuses to start in production if JWT_SECRET is shorter than 32 characters or is still the development secret. The docs record a trade-off: there's no revocation list, so a copied token stays valid until it expires, and changing JWT_SECRET signs everyone out.

Passwords. Argon2id with 64 MiB (post 3). A dummy hash makes an unknown email take the same time. The same error message for a wrong email and a wrong password. The forgot-password page shows the same message whether or not the email exists. Reset links carry a fingerprint of the current hash, so they stop working once the password has changed, making them single-use.

OAuth. Authorization code with PKCE, state compared with compare_digest, minimal scopes, linking by sub, only trusting emails Google has verified, and dropping the password of an unverified account when the email's real owner shows up (post 7).

Admin permissions. This is the easiest place to go wrong in any application. The token contains roles, but only so the interface can show menus. Real permissions are always read again from the database on every request:

def require_permission(permission: Permission):
    """For staff pages: roles are read from the database on every request, never trusted from the token."""

So locking an account or removing admin rights takes effect immediately, without waiting for tokens to expire. Admin actions also check that the Origin header is the site itself (same_origin), one more CSRF layer on top of SameSite.

Content. Blog posts are written in Markdown, rendered to HTML, then passed through nh3, an HTML sanitiser that keeps only allow-listed tags and attributes. Jinja templates escape every variable automatically. The only place using | safe is that sanitised HTML.

Database. Queries use SQLAlchemy with bound parameters, never concatenated SQL strings. The SQLite file lives in the data/ directory (blocked by .gitignore). Passwords exist only as hashes. No column needs application-level encryption (post 6).

Server and secrets. Deploys go over SSH with keys (post 8). .env and .env.production are blocked by .gitignore. The server address is read from DEPLOY_HOST in .env.production rather than written in deploy.sh, so the public repo doesn't reveal the server. Requesting https://viet.sarl/.env returns 404.

The OWASP Top 10 against this site

The OWASP Top 10 lists the most common groups of web vulnerabilities, updated every few years. Checking the main groups one by one:

Group This site
Broken access control (seeing or changing other people's data) Permissions read from the database on every request; every score action is tied to user.id from the session, not taken from the form
Cryptographic failures Argon2id, TLS 1.3, HMAC tokens with a 32+ character secret, secrets for every random number
Injection (SQL, system commands) SQLAlchemy with bound parameters; no shell calls with user data
XSS Jinja auto-escaping; post HTML through nh3; HttpOnly cookies. No CSP yet
CSRF SameSite=Lax, Origin checks for admin actions, state for OAuth
Security misconfiguration The app opens no ports to the outside; the development secret is refused in production. Some security headers missing
Vulnerable components uv.lock pins versions; the Docker image is rebuilt on every deploy. No automated scanning yet
Authentication failures Identical messages, identical timing, single-use reset links. No limit on sign-in attempts yet
SSRF (the server tricked into calling internal addresses) The app only calls Google's fixed URLs; it doesn't fetch URLs supplied by users
Logging and monitoring failures Logs exist, but no alerts for unusual patterns yet

The supply chain

The site's own code is only a small part. Most of the code running on the server comes from third-party libraries: FastAPI, SQLAlchemy, PyJWT, pwdlib, nh3, along with their dependencies, the python:3.12-slim Docker image, Caddy. A library whose publishing rights are taken over is a direct path onto the server.

What the site does and should do:

  • Pin versions and hashes: uv.lock records the exact version and hash of every package (post 2). Reinstalling gives exactly those bytes, not some unexpected new release.
  • Fewer dependencies: every package removed is one less thing to trust.
  • Vulnerability scanning (pip-audit, Dependabot) and regular updates: not yet, on the to-do list.
  • Care with new packages: check the package name (typo-squatting is a common attack), the publisher and the package's history before adding it.

What's still missing

Writing this series was also a self-review. The items below are what the site doesn't have yet, in the order worth doing:

Task Why Effort
A Strict-Transport-Security header Stops the first HTTP request being intercepted (post 5). Start with a short max-age One line in the Caddyfile
X-Content-Type-Options: nosniff, Referrer-Policy, frame-ancestors headers Stop browsers guessing file types, limit URLs leaking through Referer, stop the site being embedded elsewhere A few lines in the Caddyfile
Content-Security-Policy A second barrier against XSS: the browser only runs scripts from allowed sources Needs careful testing, since the site uses HTMX and inline scripts
Limit sign-in attempts per account and per IP Slows down guessing from leaked password lists Medium
A longer minimum password, checking against leaked lists NIST recommends at least 15 characters without MFA (post 3); the site uses 8 Small
Run the container as a non-root user A bug in the app doesn't become root inside the container One USER line in the Dockerfile, plus write access to data/
Automated dependency vulnerability scanning Know early when a package in use has a vulnerability Small
A server firewall accepting 80/443 only from Cloudflare No way around Cloudflare even if the origin address leaks Medium
Encrypted SQLite backups, with restore tests Backups contain emails and password hashes (post 6) Medium

None of these is a vulnerability being exploited. Most are second lines of defence: when the first layer, such as escaping or SameSite, has a bug, this layer still catches it. Good security is many overlapping layers, not one perfect wall.

End-of-series checklist

Cryptography

  • [ ] Every security-related random number uses secrets / os.urandom, never random
  • [ ] No home-made algorithms; vetted libraries with default parameters
  • [ ] "Signatures" with a shared key are HMAC; compare with hmac.compare_digest
  • [ ] Encryption uses an AEAD (AES-GCM, ChaCha20-Poly1305), and nonces never repeat
  • [ ] Public keys: Ed25519 / X25519; RSA only with OAEP / PSS and at least 2048 bits

Passwords and sign-in

  • [ ] Argon2id (or bcrypt, scrypt) with a salt; never bare SHA-256
  • [ ] The same message and the same timing for a wrong email and a wrong password
  • [ ] Limit attempts; block leaked passwords; encourage MFA / passkeys
  • [ ] HttpOnly, Secure, SameSite cookies; short-lived tokens; permissions read from the database
  • [ ] OAuth: authorization code + PKCE, check state, minimal scopes, link by sub

Transport and data

  • [ ] TLS 1.2+ only (ideally 1.3), auto-renewed certificates, HSTS
  • [ ] Know every TLS leg, including behind a CDN
  • [ ] Know where encryption keys live; backups encrypted and restore-tested
  • [ ] No sensitive data slipping into logs

Operations

  • [ ] Secrets never in git, logs or blog posts; secrets can be rotated without code changes
  • [ ] SSH with keys only, no root login, a firewall opening only the ports needed
  • [ ] Dependencies pinned and scanned for vulnerabilities
  • [ ] Security updates installed regularly

The whole series in one table

Post One sentence
1 Safety lies in the key and in random numbers; random is fully predicted after 624 numbers
2 Hashes give fingerprints; with a key use HMAC, compare with compare_digest
3 Passwords need slow, salted hashing: Argon2id is about 100,000 times slower than SHA-256
4 Public keys solve key exchange; Ed25519 is fast, ML-KEM resists quantum computers
5 TLS 1.3 combines everything; certificates, ACME, CT; forward secrecy
6 Encryption at rest is a story about where the key lives; it doesn't stop application bugs
7 OAuth authorises, OIDC authenticates; PKCE and state stop leaked codes and forged sign-ins
8 SSH trusts servers by TOFU and signs users in with signatures; turn passwords off
9 Security is layers, starting from "who are we defending against", with an honest list of what's missing

Security is never "done". New algorithms appear (ML-KEM was only standardised in 2024), new vulnerabilities turn up in libraries you use, and every new feature is a new attack surface. What lasts is the way of thinking: ask who you're defending against, use vetted tools exactly as designed, stack several layers of defence, and review yourself regularly, as this post did.