Four security headers, and the CSP I deferred on purpose
This site — the résumé front end and this blog — runs as one Spring Boot container behind Traefik on a single Hetzner box. Traefik terminates TLS and reverse-proxies everything, which makes it the natural place to set response security headers: one middleware, applied at the edge, covers every route the app serves without me touching a line of Java. This is a field note on what I added, and just as importantly, on the two things I chose not to add yet.
The cheapest win is at the edge
Security response headers are instructions the browser reads and enforces on your behalf. They cost nothing at runtime, need no application code, and shut down whole classes of attack. On a single-origin setup like mine, the honest place for them is the proxy — not scattered through controllers. In Traefik that means a headers middleware, declared as Docker labels and attached to the router that serves both the apex and the blog host.
- traefik.http.routers.cvnext.middlewares=cvnext-secheaders
- traefik.http.middlewares.cvnext-secheaders.headers.stsSeconds=63072000
- traefik.http.middlewares.cvnext-secheaders.headers.stsIncludeSubdomains=true
- traefik.http.middlewares.cvnext-secheaders.headers.contentTypeNosniff=true
- traefik.http.middlewares.cvnext-secheaders.headers.frameDeny=true
- traefik.http.middlewares.cvnext-secheaders.headers.referrerPolicy=strict-origin-when-cross-origin
Four headers, one middleware. Here's what each one actually buys.
What the four headers do
HSTS — Strict-Transport-Security: max-age=63072000; includeSubDomains. That number is two years in seconds. It tells the browser: for the next two years, never talk to this host over plain HTTP — rewrite http:// to https:// internally before anything hits the wire. That's the defense against SSL-stripping on a hostile network. Two subtleties worth internalizing: the header is only honored when received over HTTPS (so a first-ever HTTP visit ignores it — your HTTP→HTTPS redirect is what gets the user onto TLS in the first place), and includeSubDomains extends the promise to every subdomain, which is exactly why every *.zakaria.lu has to stay HTTPS forever.
nosniff — X-Content-Type-Options: nosniff. Kills the browser's legacy MIME-sniffing, where it guesses a response's type from its bytes and ignores your declared Content-Type. That guessing is a real attack surface for anything user-uploaded — a file served as text/plain that happens to contain markup could get interpreted as HTML. nosniff forces the browser to trust the declared type and refuses to run scripts served with a non-script MIME type.
frame-deny — X-Frame-Options: DENY. Forbids the page from being embedded in an <iframe> at all. That's the anti-clickjacking control: without it, an attacker can load your app invisibly over their own page and trick a logged-in user into clicking real controls. DENY blocks all framing, even same-origin.
referrer — Referrer-Policy: strict-origin-when-cross-origin. Under this policy a same-origin navigation sends the full URL, a cross-origin one sends only the scheme+host (no path or query), and an HTTPS→HTTP downgrade sends nothing. Third parties never see your full URLs. It's the modern browser default now, so I'm setting it explicitly rather than depending on the default staying put.
What I deliberately did NOT ship
Two omissions, both on purpose.
HSTS preload. Adding stsPreload=true and submitting to hstspreload.org bakes the domain into a list shipped inside the browsers themselves, so they treat you as HTTPS-only before the first visit ever happens. Powerful — it closes the first-visit gap — but it's effectively a one-way door: removal propagates through browser release cycles over months, and it commits the whole domain and every subdomain to HTTPS. I'm not turning that on until I'm certain every current and future *.zakaria.lu will always be HTTPS.
Content-Security-Policy. CSP is the heavy hitter — it constrains where scripts, styles, and images may load from and can forbid inline scripts entirely, which is the strongest XSS mitigation you can deploy. It's also the one most likely to white-screen your site the moment you enable it, because a strict policy breaks anything it didn't anticipate. So it deserves its own careful rollout rather than being bolted on next to the easy headers.
The CSP I'm lining up — in report-only first
Before writing a byte of policy, I inventoried what the two hosts actually load. That step is the whole game: a CSP is only as good as its match to your real asset sources, and I found three things a naïve policy would have broken:
- Google Fonts on both hosts — the SPA and the server-rendered blog both pull CSS from
fonts.googleapis.comand font files fromfonts.gstatic.com. That forces explicitstyle-srcandfont-srcallowances. - An external CDN script on the blog —
highlight.jsfromcdnjs.cloudflare.com(with SRI), soscript-srchas to list it. - An inline
<script>on blog posts — the little handler that colorizes code blocks after load. Inline scripts are the single hardest CSP decision.
And there's a structural constraint that shapes everything: because I set CSP as a static Traefik label, I cannot use nonces. A nonce has to be freshly generated per response by the application and echoed in both the header and the tag; Traefik emits the same header for every response, so nonces are off the table. For a proxy-set CSP the only tools for inline code are the blunt 'unsafe-inline' or a 'sha256-...' hash of a known-static block. The clean fix is to stop having inline script at all — move that colorize handler into a static .js file so it becomes plain 'self'.
One nuance I like: it's fine to keep 'unsafe-inline' on style-src while forbidding it on script-src. React and the TipTap editor both set inline styles (dynamic animations, editor formatting), and style injection is a minor threat next to script injection — which is where XSS actually executes. So the target policy allows inline styles but not inline scripts.
The rollout is Content-Security-Policy-Report-Only first: it enforces nothing but flags violations, so I can watch what would break before flipping the switch. Which brings me to the catch nobody mentions.
The honest catch about report-only
Report-only with no report-to or report-uri directive doesn't send reports anywhere central — violations land in each visitor's browser console and nowhere else. There's no dashboard unless you build a collector. For a low-traffic personal site the pragmatic loop is: ship the report-only header, open your own apex and blog, exercise every path (login, the AI concierge stream, a code-heavy blog post, the editor), and watch your own console for violation lines. When it's clean across all of them, rename the header from report-only to enforcing. Anything less and “report-only” gives you a false sense that you're collecting data you never actually see.
Takeaways
The pattern I keep coming back to: ship the cheap, reversible wins immediately; gate the powerful, hard-to-reverse ones behind a real rollout. Four headers at the proxy took one middleware and zero risk. HSTS preload and CSP are deferred not out of laziness but because both are commitments — one irreversible, one liable to break the site — and commitments deserve a plan, not a hurried label. Inventory first, report-only before enforce, and be honest with yourself about whether you're actually reading the reports.