Skip to main content

Your Security Headers Probably Cover Half Your Site

security http headers web development astro ssr

Here is a check worth running on your own site before you read any further:

for p in / /about /blog /pricing; do
  n=$(curl -sI "https://yoursite.com$p" \
    | grep -icE 'strict-transport|x-frame|x-content-type|referrer-policy|permissions-policy')
  printf "  %-12s %s/5\n" "$p" "$n"
done

If some routes come back 5 and others come back 0, this post is about you. (If they all come back 0, you have a simpler problem - our complete guide to HTTP security headers covers what each one does and why you want it.) We ran it on our own site and got a clean split: every static page returned nothing, one dynamic route returned everything. Our security headers had been half-applied for months and every local test said they were fine.

The failure is structural, not a bug

Modern frameworks render a single site through two different paths. Some pages are built to files at deploy time. Others are rendered per request. This is the whole point of hybrid rendering and it is usually invisible - the same layout, the same components, the same URLs.

It stops being invisible the moment you attach something to the request pipeline.

Middleware runs on requests the server actually handles. A prerendered page is a file on disk, answered by a static file handler that sits in front of the framework’s request pipeline. The response goes out before your middleware is ever called. Your headers are not stripped, they are never added.

So the split is:

  • Server-rendered routes pass through middleware and get your headers.
  • Prerendered routes are static files and get nothing.

The reason this survives review is that both halves look right in isolation. The middleware code is correct. The headers appear when you test. The build succeeds. Nothing anywhere reports a problem, because from the framework’s point of view there isn’t one - you asked for middleware on server-rendered responses and that is exactly what you got.

And the ratio is the cruel part. On a marketing site, brochure site or docs site, nearly everything is prerendered. The pages a visitor actually lands on are the unprotected ones. The handful of dynamic routes that do work are the ones a developer is most likely to spot-check.

This shape shows up anywhere a framework mixes prerendered and server-rendered output. The details below are Astro, because that is where we hit it and can speak to the specifics, but if you are on another hybrid framework the question to ask is the same: does my middleware run for statically generated pages? The answer is usually no.

The Astro case, in detail

With output: 'server' and the Node adapter, the typical setup puts headers in middleware:

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';

const SECURITY_HEADERS = {
  'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
  'X-Frame-Options': 'SAMEORIGIN',
  'X-Content-Type-Options': 'nosniff',
  'Referrer-Policy': 'strict-origin-when-cross-origin',
  'Permissions-Policy': 'geolocation=(), camera=(), microphone=()',
};

export const onRequest = defineMiddleware(async (_context, next) => {
  const response = await next();
  for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
    response.headers.set(key, value);
  }
  return response;
});

Correct code, covering a minority of pages.

The Node adapter’s other half is a _headers.json in the build output directory, which it applies to static responses. Nothing writes that file for you, so you generate it in an astro:build:done hook:

'astro:build:done': async ({ dir, pages, logger }) => {
  const entries = pages
    .map((page) => `/${page.pathname.replace(/^\/|\/$/g, '')}`)
    .sort((a, b) => b.length - a.length)
    .map((pathname) => ({ pathname, headers }));

  // For an SSR build, `dir` is the client directory - the file goes in its parent.
  const outDir = new URL('../', dir);
  await writeFile(new URL('_headers.json', outDir), JSON.stringify(entries));
  logger.info(`security headers written for ${entries.length} prerendered routes`);
}

Two traps in that hook.

pages does not include endpoints. The array is exactly what it says: pages. A prerendered endpoint - an llms.txt.ts or a feed.xml.ts carrying export const prerender = true - never appears in it, so it never lands in _headers.json. It is not server-rendered either, so middleware skips it. It falls between both mechanisms and gets nothing from either.

This one is genuinely invisible. The build log prints a confident count of covered routes, and the count is accurate - it just is not the whole set. We only found it by diffing the generated file against the sitemap.

The path matching runs in the direction you would not guess. The adapter finds the first entry where entry.pathname.includes(requestPath) - the entry has to contain the request path, not the other way round. A request for / therefore matches any entry at all, since every path contains a slash, and whichever entry sorts first wins.

Sorting longest-first is what stops that being visibly wrong. When every route carries identical headers you will never notice, because the wrong entry holds the right value. The day you want different headers on different routes, this rule will bite, and it will present as a caching bug.

Keep the list in one module

Two mechanisms reading two copies of the same header list will drift, and the drift is silent because each copy works on its own half. Export the object once and import it from both the middleware and the build hook.

One aside while you are in there: leave preload off your HSTS header unless you have actually submitted the domain to the preload list. Sending the directive without submitting buys nothing and invites someone to submit later. Getting back off that list takes months.

Consider putting them at the edge instead

If you sit behind a CDN or reverse proxy that can set response headers, the blunt option is to set them there and delete both in-app mechanisms. One rule covers every route - prerendered, server-rendered, endpoints, static assets - regardless of what the origin does.

We are increasingly convinced this is the right default for a static header list. Two build-time mechanisms that can each half-work silently are more moving parts than five headers deserve. The tradeoff is that they then live outside your repo and are no longer reviewed alongside the code, which matters if you are actively iterating on a CSP and matters very little for a list that changes once a year.

Check the deployed site, not localhost

This is the part we got wrong, and it generalises past headers.

We tested locally, saw all five on every route, and moved on. Both mechanisms worked locally. In production only the middleware half did. The code was identical; something between the build output and the visitor was not, and no amount of local testing was ever going to surface it.

Anything that depends on how your artifact is packaged, mounted or proxied has to be verified against the deployed thing. Headers, redirects, caching, compression, health checks - the whole category. A green local test tells you the code is right, not that the deployment is.

Run the curl loop at the top of this post against production. Our free technical audit grades these headers too, if you would rather see them scored alongside the rest of your site’s technical health - it is what eventually caught ours. If your monitoring says you are fine and you have never checked the deployed response directly, check it. Ours said fine for months, and the thing that eventually told us was an automated scan of our own site.

Need help shipping?

We help teams build and ship software that works. Performance, SEO, features, weekly demos, full ownership.

Tell Us What's Stuck