SYS://FIELD-REPORT-11
Four Headers, Five Minutes: The Least Glamorous Finding on the Audit
The app looked sharp. Fast, static, deployed to the edge, nothing obviously broken. Then I ran the response headers through a scanner and got back a wall of red — not because anything was leaking, but because the browser had been told nothing about how to defend the people using it. No CSP. No HSTS. No clickjacking guard. This is the least dramatic finding on the entire audit, and it is skipped almost every single time. Here’s the teardown, worst-first.
This is a representative audit — the exact pattern I hit over and over on apps built with Lovable, Bolt, v0, and Cursor. Names and paths are changed, but every finding below is real-shaped and drawn straight from the kind of report the launchworthy skill produces. Call the app routeledger: an Astro site on Cloudflare Pages, static output, about to go live.
Nothing here will breach it tonight. That’s precisely why it never gets fixed.
The finding: the browser was handed no instructions
Security headers are the response headers that tell a browser how to defend your users: force HTTPS, block being framed by another site, stop MIME sniffing, and constrain what the page is allowed to load. routeledger set none of them.
I want to be honest about severity, because inflating this for drama is exactly the move that makes an audit untrustworthy. This is not a CRITICAL. Nothing here hands an attacker your database. On the checklist it breaks down like this:
- Missing Content-Security-Policy →
[HIGH]. Nothing constrains what scripts the page can load, so an injected script has free rein. - Missing Strict-Transport-Security (HSTS) →
[HIGH]. The browser will happily try HTTP first, which is where a downgrade or a coffee-shop attacker lives. - Missing X-Frame-Options →
[MEDIUM]. Any site can load routeledger in an invisible iframe and clickjack a real user’s clicks. - Missing X-Content-Type-Options →
[MEDIUM]. The browser is allowed to MIME-sniff, guessing a file is a script when it shouldn’t be.
HIGH · CAUGHT
No security headers on any response
routeledger ships with an empty header set. Its public/_headers file either doesn’t exist or carries only cache rules. The browser is never told to force HTTPS, never told it can’t be framed, and never given a content policy — so it defaults to “allow.” Two HIGH, two MEDIUM, all on one config file.
The interesting thing about this finding is not how dangerous it is. It’s how trivially it fixes. This is the single fastest win on the whole punch list.
THE FIX · 5 MIN
Astro on Cloudflare Pages reads a public/_headers file at the project root. Drop this in and redeploy:
/*
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()That is the safe, high-value four (plus Referrer-Policy and Permissions-Policy, which cost nothing). No app-specific tuning, no risk of breaking a working page. Paste, push, done.
CSP is the one to add carefully, because a strict policy can break a working app if it forgets a domain you actually use. Ship it in report-only mode first so violations are logged, not enforced:
Content-Security-Policy-Report-Only: default-src 'self'; img-src 'self' data: https:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://your-api.example.com https://*.supabase.co
Watch the console for a few days, add the domains your app legitimately uses — analytics, fonts, your API — then flip it from -Report-Only to enforcing. Do not ship a strict enforcing CSP blind; you will break something and not know why.
The false alarm: “Cloudflare already protects me”
Here’s the reason this finding survives on so many apps. The owner has already answered it in their head, and the answer feels right:
FALSE POSITIVE
⚠️ I'm on Cloudflare — my edge already handles security headers.
It doesn’t, and the confusion is understandable. A CDN’s DDoS mitigation, edge caching, WAF, and TLS termination are real protections — but they operate at the network and platform layer. Application security headers are a different thing entirely. Cloudflare will absorb a flood and terminate HTTPS for you. It will not set your CSP, your HSTS, or your X-Frame-Options unless you configure them. The default response carries none of them.
The distinction is worth internalizing, because the same trap catches Vercel and Netlify users:
- Platform / edge protection (DDoS, WAF, TLS, caching) → on by default. This is what “Cloudflare protects me” correctly describes.
- Application security headers (CSP, HSTS, X-Frame-Options, X-Content-Type-Options) → off until you write them. On Cloudflare Pages that means a
public/_headersfile. Nothing sets them for you.
Being behind a good CDN is not the same as telling the browser how to behave. The header block above is the part only you can add.
Verify by hand After you redeploy, run the site through securityheaders.com or open DevTools → Network, click the document request, and read the response headers. Confirm HSTS and the four base headers are present. That’s your evidence the fix landed — a green scan where there was a red one five minutes ago.
The rest of the punch list
Headers were the fastest fix. They were not the only finding — a full audit walks all five domains. The rest is ranked so scope stays honest:
FIX THIS WEEK
After the headers
- [HIGH] No input validation on the one mutating endpoint —
src/pages/api/submit.ts:14writes the request body straight through. Add a Zod schema before it touches storage. - [HIGH] No error tracking anywhere — no Sentry, no global handler. When something breaks in production you find out from a user, not a system.
- [MEDIUM] No
404.astro/500.astro, so an error is a bare server page instead of your own. - [MEDIUM] Images in
src/components/Hero.astromissingaltattributes. - [LOW] A couple of icon-only buttons with no
aria-label.
Notice the shape. The headers were two HIGH and two MEDIUM that collapse into a single paste. The validation gap is real week-one work. And the alt attributes are genuinely fine to leave until next month. A flat list reads as nine equal chores. Ranked, it reads as “paste the header block now, do the two HIGHs this week, breathe.”
ASK YOURSELF
“I don’t have time to fix all this.” You don’t have to. The punch list is ranked exactly so you don’t. This one is five minutes and a single file — it’s the item on the list you finish before your coffee’s cold. The ranking exists so you can ship safely without doing everything at once.
Why this keeps happening
Security headers get skipped because they are invisible. They don’t show up on your screen, they don’t throw an error, and the app works identically with them or without them — right up until an injected script or a clickjacked click makes the difference. AI builders optimize for “looks finished,” and a header you can’t see doesn’t make the demo look any more finished. So it never gets written.
That’s the gap launchworthy exists to close. It plays bouncer at the door of production: it walks the response headers, tells you a missing CSP is a HIGH and a missing X-Frame-Options is a MEDIUM — not a fake CRITICAL to scare you — knows that your CDN isn’t setting them for you, and hands you the exact public/_headers block instead of just the finding.
The least glamorous item on the audit is also the cheapest one you’ll ever fix. Four headers. Five minutes. One file you paste once.