post: Security Headers Missed the HTML Pages_□×

Security Headers Missed the HTML Pages

The security headers on my docs site reached the API responses, but not the prerendered article pages. The middleware covered one response path. The pages readers opened used another.

The site uses Astro with the Node adapter. Its article routes are configured to prerender, while API routes render on demand. That split matters when deciding where a security policy belongs.

Build-time middleware isn’t the serving layer

Astro middleware runs when a page is rendered. For a prerendered page, that happens during the build. For an on-demand route, it happens when a request arrives.

The article pages were therefore already HTML files when visitors requested them. In this deployment, serving those files didn’t run the application middleware again. Headers set on a response during rendering weren’t automatically preserved as headers on the later static-file response.

The security helper could work correctly for an API endpoint without changing what the server sent with an article. Testing the helper alone wouldn’t establish that the HTML response had those headers.

This is a deployment boundary, not a reason to avoid prerendering. The policy needs to reach the browser through a mechanism supported by the layer serving the page.

What can go in the HTML

A meta tag isn’t a general way to set response headers. Putting a header name into http-equiv doesn’t make the browser treat it as one.

Content Security Policy has a supported meta delivery mechanism. Referrer policy has a separate one:

<meta name="referrer" content="strict-origin-when-cross-origin" />

That uses name, not http-equiv. It lets the generated page carry a referrer policy without depending on the application middleware.

Other protections still belong in the response headers. For these static pages, X-Frame-Options, X-Content-Type-Options, and Permissions-Policy need configuration in the server, reverse proxy, or hosting platform that serves them.

Meta-delivered CSP also has limits. The frame-ancestors directive, which controls who may embed the page, isn’t supported there. Adding CSP to the HTML doesn’t resolve framing protection. That still needs an appropriate response header.

The change therefore added policies the HTML could carry and documented the remaining hosting requirements. It didn’t make the two delivery mechanisms interchangeable.

Hash the script that needs to run

The site now uses Astro’s security.csp to generate a CSP meta tag. Astro handles its processed scripts and styles, while the site’s custom inline theme script gets an explicitly supplied hash.

That script applies the theme before the page paints. It needs to run, but allowing it shouldn’t require allowing arbitrary inline scripts.

A hash fits this static build: the script text is known in advance, so its digest can be generated alongside the HTML. A fresh per-request nonce would require a serving layer that updates both the policy and the script markup.

The hash comes from the same string used to render the script:

import { createHash } from 'node:crypto';
import { themePrepaintScript } from './themes';

export const themePrepaintScriptHash = `sha256-${createHash('sha256')
  .update(themePrepaintScript, 'utf8')
  .digest('base64')}`;

That value goes into scriptDirective.hashes. Editing the script regenerates its hash during the build, instead of requiring someone to remember to replace a pasted digest.

The computation also lives in a build-only module. The theme definitions are shared with a browser component, so placing the Node crypto import in that shared module would cross the wrong boundary.

Check what reaches the browser

Checking headers is still useful, but curl -I won’t show a policy delivered inside the HTML. Verification needs both.

For a built and deployed article page, check:

  • The response headers supplied by the hosting layer.
  • The CSP and referrer meta tags in the HTML.
  • Browser policy violations and whether the theme script still runs.

Check an API response separately against its intended policy. The two responses don’t need identical headers; they need the protections appropriate to what they serve.

Astro’s CSP feature isn’t active in dev mode, so this also needs a production build and preview, followed by a check of the deployed site. Preview can verify generated HTML without proving that the hosting configuration is correct.

I want to check the page the browser receives, not just the middleware that was supposed to protect it. For prerendered pages, that means checking both the HTML and the server’s response headers.

Sources

I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].

START
llbbl.exeprojects/posts/experiments/subscribe.dlg
© 2026v1.0.0