The only security header our site sends is HSTS, and 74 of our 76 pages tell any origin it may read them
Nakodo has 76 public pages. I fetched the headers of all 76 and counted the names: 76 age, cache-control, content-type, date, server, strict-transport-security, vary, x-matched-path, x-vercel-cache, x-vercel-id 7
Nakodo has 76 public pages. I fetched the headers of all 76 and counted the names:
76 age, cache-control, content-type, date, server, strict-transport-security,
vary, x-matched-path, x-vercel-cache, x-vercel-id
74 accept-ranges, access-control-allow-origin, content-disposition,
content-length, etag, x-nextjs-prerender, x-nextjs-stale-time
2 link, x-powered-by
Eighteen names. The list that is not there is the interesting one: no content-security-policy, no x-frame-options, no x-content-type-options, no referrer-policy, no permissions-policy, no cross-origin-opener-policy. Our next.config.ts has no headers() function in it at all, so every header above comes from Next.js or from the platform, and the security posture of the site is whatever those two decided.
This post is that audit, done honestly, including the part where one of the recommended headers turns out to be expensive for us and the other four do not.
The one we do send
strict-transport-security: max-age=63072000
Two years, on every page and every asset. We did not write it; the platform adds it. What it does not say is includeSubDomains, and it does not say preload, so it is the weakest of the three useful shapes of that header: it protects nakodo.app for a visitor who has already been there once, and says nothing about anything else.
That is a decision I am fine with having had made for me, and includeSubDomains is one I would want to make deliberately rather than inherit, because it applies to every subdomain forever, including ones that do not exist yet.
The 74 and the 2
Four of the differences in that count are the same two pages. / and /pricing are the only pages that:
- send
x-powered-by: Next.js - send a
link:header with ten font preloads in it - do not send
access-control-allow-origin: * - have
age: 0instead ofage:up to 764,000 seconds
They are the two dynamic pages on the site, for a reason I wrote up when I measured the edge cache: both of them read a request header to pick a currency, which makes them render per request. The other 74 are prerendered files, and the platform serves files with the headers it gives files.
So the header differences are not a policy. They are a side effect of how a response was produced, which is the thing to understand about header audits on a framework: you are not auditing your decisions, you are auditing your rendering strategy.
access-control-allow-origin: * on 74 HTML pages is the one that made me look twice. It means any website's JavaScript may fetch("https://nakodo.app/guides/...") and read the HTML. For public marketing pages that is close to meaningless: the content is public, no cookies are sent cross-origin without credentials, and anybody can read it with curl anyway. It is still worth knowing, because the day a prerendered page contains something personal, that header is already there and nobody will think to look.
Worth saying out loud: the two pages that do read something from the request are precisely the two without that header. That is a coincidence, not a design.
x-powered-by: Next.js on two pages is pure information disclosure and the fix is one line, poweredByHeader: false. It tells an attacker nothing they could not infer from /_next/static/ in our HTML, which is why it survived this long, and it is still free to remove.
What a CSP would cost, measured
I keep not shipping a Content Security Policy, so here is the actual arithmetic rather than the usual guilt.
What our pages load, measured on the live site:
- 17 script files on the home page, every one of them from our own origin
- 4 inline scripts on the home page
- 10 font files, self-hosted, no request to Google
- images through our own server, because
next.config.tslists the platforms inimages.remotePatternsand the optimiser fetches them server side - analytics through our own path, because the config rewrites
/alcy/*onto PostHog, so the browser only ever talks to us - external hosts named in the home page's HTML:
schema.org(a JSON-LD context string, never fetched),youtube.comand a sibling app (both links)
One genuine third party exists in the whole product: Cloudflare Turnstile, loaded as https://challenges.cloudflare.com/turnstile/v0/api.js on the sign-up form, which also renders an iframe. It is not in the HTML of the sign-in page, because the script is only injected once you switch that form to creating an account.
So the policy almost writes itself:
default-src 'self';
script-src 'self' https://challenges.cloudflare.com;
frame-src https://challenges.cloudflare.com;
img-src 'self' data:;
font-src 'self';
connect-src 'self';
frame-ancestors 'none';
base-uri 'none';
And then the four inline scripts stop it working, and that is where the cost is.
Inline scripts need 'unsafe-inline', which makes the whole exercise decorative, or a nonce, or a hash. A nonce has to be unique per response, which means generating it in the proxy and reading it during render, which means the response cannot be a prerendered file any more. Our 74 cached pages are 74 cached pages because they are files. A nonce-based CSP turns them into 76 dynamic renders, and the measurement I already have for that is a jump from 0.09s to about 0.25s of time to first byte, on every page, for every visitor.
Hash-based is the honest path for a site shaped like ours: the inline scripts are Next's own bootstrap, their contents are fixed at build time, so their hashes are too. That is a build step that reads the output and writes a static headers() entry, which is more work than a nonce and costs nothing at runtime.
Which is the real reason this is not done yet, and now it is written down with numbers next to it instead of being a vague intention.
The four that are free
None of this applies to the other four. They are static strings, identical on every response, and headers() in next.config.ts applies them to prerendered files without making anything dynamic:
x-content-type-options: nosniff-
referrer-policy: strict-origin-when-cross-origin(what browsers now default to, made explicit) -
x-frame-options: DENY(andframe-ancestors 'none'when the CSP lands) -
permissions-policy: camera=(), microphone=(), geolocation=(), since we ask for none of those
That is the job this audit produced: four headers and poweredByHeader: false in one commit, the CSP hashes as a separate piece of work.
One more thing the sweep found
Not a header, but I was in the neighbourhood. Our web manifest says:
{ "start_url": "/campaigns", "display": "standalone", "theme_color": "#f9f5ed" }
/campaigns is behind the login. So installing the app from a marketing page and tapping the icon opens a 307 to the login form, which is correct for a customer and a strange first impression for anybody else:
$ curl -sI https://nakodo.app/campaigns | head -2
HTTP/2 307
location: /login?next=%2Fcampaigns
You can read the whole thing yourself at nakodo.app/manifest.webmanifest, which is 476 bytes and, like 74 of our pages, is served with access-control-allow-origin: *.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.