# A hand-authored, self-verifying CSP for Astro on Vercel — after security.csp broke offline support

> Astro's own security.csp feature silently breaks service-worker registration. A hand-authored CSP for Astro on Vercel, verified by CI and a real browser.

- Source: https://oharu121.com/blog/astro-vercel-security-csp-service-worker-hash/
- Published: 2026-09-03T00:26:46+09:00
- Tags: Astro, Vercel, Service Worker, Security

---
## Introduction

I submitted this blog's URL to Trend Micro's Site Safety Center, hoping for a clean bill of health. What came back, on September 2, 2026, was a reclassification result rating the site **Dangerous**, with a content type of **Disease Vector**.

*Image: Trend Micro Site Safety Center reclassification result for oharu121.com, dated September 2, 2026, 11:29 AM UTC, showing New Safety Rating: Dangerous and New Content Type: Disease Vector*

*The reclassification result. The site had previously been "Unrated" / "Untested."*

Neither label means what it sounds like for a static blog with no comment forms, no user uploads, and no third-party scripts. But I'd read that missing security headers can factor into how some scanners score a site, and this blog shipped none: no Content-Security-Policy, no HSTS, nothing. That was worth closing regardless of whether it was the actual cause here.

What I asked for was a careful implementation, researched against current best practice rather than copied from a template. What I got was a working Content-Security-Policy and the standard hardening headers, but not from the path either of us expected going in: **Astro's own official CSP feature turned out to have a real bug**, one that would have shipped a broken service worker to every reader if a real browser hadn't caught it first. This article is about that detour, and the two-tier verification it left behind. Whether it actually satisfies Trend Micro's scanner is still an open question. I haven't resubmitted the URL yet.

## The obvious path: Astro's own CSP feature

This site is a fully static Astro blog on Vercel: every page prerendered, no third-party scripts, no CDN fonts, nothing loaded from anywhere but its own origin. That should make it one of the easiest sites on the internet to lock down with a strict policy.

Astro has had stable, non-experimental CSP support since Astro 6.0, well before the version this site runs. Enable `security.csp` and Astro hashes every script and style it processes at build time, then either injects a `<meta>` tag or, paired with the Vercel adapter's `staticHeaders: true` option, writes a real `Content-Security-Policy` header per page. The pitch is exactly what a static site wants: no hand-maintained hash list, correct per-page hashing even for content that differs on every page, like JSON-LD.

The agent building this tried that first. It didn't even build on the first attempt:

```
Astro found issue(s) with your configuration:

! security.csp: Did not match union.
  > Expected type boolean | Directives script-src and style-src (including
    their -elem/-attr variants) are not allowed in security.csp.directives.
    Please use security.csp.scriptDirective and security.csp.styleDirective
    instead, scoping resources/hashes to the more specific directives with
    the kind option ("element" or "attribute").
```

**The fix was in the error message**: a `styleDirective.resources` entry with `kind: "attribute"`, not a bare string in the top-level `directives` array. The build went green, but a new warning appeared and stayed:

```
[WARN] [config] Shiki syntax highlighting uses inline styles that are not
compatible with Content Security Policy (CSP). Consider using Prism syntax
highlighting instead, or disable CSP if Shiki is required.
```

This blog's code blocks render through Shiki with a light and dark theme, which turns out to mean every highlighted token carries its own `style="--0:#24292e;--1:#e1e4e8"` attribute for the two color values. **That's a real, unavoidable mechanism, not a misconfiguration**: Shiki's dual-theme output has to put the colors somewhere, and CSS custom properties on the element are how it avoids rendering the whole code block twice. 125 of the site's built HTML files carried at least one. The fix was narrow and deliberate: scope `'unsafe-inline'` to `style-src-attr` specifically, which doesn't touch the hash enforcement on actual `<style>` elements.

## What the browser actually saw

A CSP header only matters if a browser enforces it, and there was no easy way to check that locally. `pnpm preview` serves the built site with a plain static file server that attaches no headers at all, and with the adapter's `staticHeaders: true` option on, Astro stops emitting the `<meta>` tag too, so there was nothing left to test against on a laptop. The agent wrote a small script: a local HTTP server that reads the real headers Astro generates and serves the build with them attached, then a headless-browser pass over a handful of representative pages, checking the console for anything naming Content-Security-Policy.

The first run found violations on every single page:

```
Executing inline script violates the following Content Security Policy
directive 'script-src ...'. Either the 'unsafe-inline' keyword, a hash
('sha256-CUF1UsP1KF+rujto8WMYHvUjA3RVvt9YEmoxguvdzDs='), or a nonce
('nonce-...') is required to enable inline execution. The action has been
blocked.
```

**The blocked script was the service worker registration.** Every page on this site registers a service worker for offline reading, and Astro's hashing simply never covered it. Tracing the exact bytes back to source (matching each unmatched hash against a `sha256` of the actual rendered `<script>` content) turned up a pattern: scripts written directly in the site's top-level layout got hashed correctly, but the same kind of `is:inline` script, written in a child component the layout imports, did not. A search-shortcut script and an offline-page script had the identical problem. **This wasn't a caching artifact**: the agent cleared every build cache (`node_modules/.astro`, `dist`, `.vercel/output`) and reran from nothing, and the same scripts came back unhashed.

Some `<style>` blocks had the same gap, and less predictably. On one article page, two component styles went unhashed while a third, sitting right next to them in the same page, was hashed correctly. All three were the same kind of scoped component style; nothing about the diff explained why one was covered and two weren't.

**One open question resolved cleanly in the other direction.** JSON-LD blocks (`<script type="application/ld+json">`) carry different content on every page, title, date, description, which made them the reason Astro's own hashing looked attractive in the first place. They never showed up in a single violation, on any page, regardless of content. **`script-src` turns out not to gate a script element whose type isn't a JavaScript MIME type at all**, so this was a non-issue from the start; it just took a real browser to be sure.

*Figure — CspRequestFlow: A Content-Security-Policy header only constrains what the browser will run or render after the page arrives; it says nothing about what a build tool hashed correctly to get there.*

## Hand-authoring the policy instead

**A CSP that silently breaks offline reading on every page is worse than no CSP**, so the agent recommended abandoning Astro's automatic hashing and hand-authoring the policy in `vercel.json` directly. I approved the pivot once the failure was demonstrated, not before; a broken service worker across the whole site was not a risk worth carrying for a "no hand-maintained hash list" convenience.

Hand-authoring solves the reliability problem but reopens the one Astro's hashing was supposed to close: a handful of scripts on this site are genuinely fixed (a theme script, the search shortcut, the offline page, and the service worker registration itself), so they can be hashed once and pinned. But Astro's default build also inlines small per-page stylesheets and small bundled `<script type="module">` chunks directly into the HTML when they're small enough, and which chunks that is varies by page, depending on which components that page happens to use. A single sitewide policy can't chase a value that changes per page, so `build.inlineStylesheets: 'never'` and `vite.build.assetsInlineLimit: 0` in `astro.config.mjs` force all of that external instead. `style-src 'self'` and `script-src 'self'` then cover it uniformly, with nothing left to hash by hand for that part.

The original plan also assumed the seven inline `style=""` attributes on the site could simply be refactored away into CSS classes, closing the CSP gap without any `'unsafe-inline'` at all. **That assumption didn't survive a proper count.** A grep for `style={` turned up nine more: several article diagram components set the opacity of individual SVG shapes from a formula, one opacity value per data point in a loop. There's no small set of CSS classes to convert a continuously computed value into, so `style-src-attr 'unsafe-inline'` stayed in the hand-authored policy too, **this time as a deliberate, scoped decision rather than something discovered by accident**.

> **HEADS UP**
>
> **HSTS's `preload` flag was the one call in this whole project I made directly, not the agent.** Once submitted, `includeSubDomains` binds every subdomain the domain will ever have, and removal takes 6 to 12 weeks to clear most browsers.

I don't run anything else on this domain and don't plan to, which is the one fact that made the decision straightforward. The full tradeoff:

| | No `preload` | With `preload` |
| --- | --- | --- |
| When HTTPS is enforced | From a visitor's first HTTPS visit onward | From the very first request any browser ever makes, even before a first visit |
| What `includeSubDomains` binds | This domain, going forward | Every subdomain, forever, until removal propagates |
| To undo | Change or drop the header | Submit for removal, then wait 6 to 12+ weeks for it to clear most browsers |

## Keeping the policy from going stale

A hand-authored hash list is only as good as the discipline that keeps it current, and CLAUDE.md's own rule for this project is not to trust discipline where a script can check instead. **Two different things can go wrong here, and they need two different checks.**

*Figure — TwoWayCspCheck: A stale pinned hash and a brand-new violation fail differently, so one check catches each.*

**The first failure mode is narrow and mechanical**: one of the four fixed scripts changes, and whoever edited it forgets to regenerate its hash. `scripts/compute-csp-hashes.ts` reads the actual built HTML (not the source, since hashing has to match Astro's rendered output byte for byte) and either writes the current hash set into `vercel.json` (`pnpm csp:build`) or checks it's already correct and exits non-zero if not (`pnpm csp:check`). That check now runs in CI, right after the build step, since it needs the built HTML to compare against.

The second failure mode is the one no fixed hash list can catch: a future MDX component with its own inline script or style, added by someone who has never read this article. **Only a real browser enforcing the real policy against the real page can find that**, which is what `scripts/check-csp-sweep.ts` does: it serves the built site with the exact headers read live out of `vercel.json`, so it can never test a policy that has already drifted from what ships, then loads every published article in every language it has, plus the utility pages, in headless Chromium. `pnpm csp:sweep` runs the whole site this way; `pnpm csp:sweep <slug>` checks one article. It isn't wired into CI. This repo already has a precedent for exactly that call, a Playwright-based check for figures that overflow their boxes, and the reasoning carries over unchanged: no workflow here installs a Chromium binary, and that's a cost worth avoiding for a check that only matters when someone touches a script, a style, or a component.

Even the sweep script needed a fix once it existed. Its first run reported violations on a Vercel Analytics endpoint that doesn't exist locally:

```
Refused to execute script from '.../_vercel/insights/script.js' because its
MIME type ('') is not executable, and strict MIME type checking is enabled.
```

**That reads like a CSP violation and isn't one.** `X-Content-Type-Options: nosniff` was blocking a 404 response with no `Content-Type` header from being executed as a script, a real browser protection, just the wrong one. The console-message filter had been matching on "refused to" generically instead of requiring "Content Security Policy" specifically, so it was catching both. Narrowing the filter, and giving the local server's 404 responses a real `Content-Type`, made the false positive disappear without touching the actual check.

## Verification: what's live now, and what still isn't

`pnpm csp:sweep` against the full site: **158 pages checked, zero violations.** `pnpm check` and `pnpm build` both clean, and the service worker's precached shell grew from 326KB to about 445KB (all the CSS and JS that used to be inlined is now external, and therefore precached), still under its own 500KB budget.

The first live check after deploying was a false alarm of its own. `curl -sI https://oharu121.com/` right after the merge landed came back with no `Content-Security-Policy` header at all, and an `Age: 333` on the response: **a CDN cache hit from before the production deploy had actually finished.** Waiting for the deploy job to complete and re-requesting with a cache-busted URL showed the real thing:

```
Content-Security-Policy: default-src 'self'; img-src 'self' data:; font-src
'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors
'none'; connect-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline';
script-src 'self' 'sha256-FSnQm9awVo7jad+j9vKPRI9jNpEpWw3TfPfEjTiRH00=' ...
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
```

Same result on an article page and on the one route this static site actually runs server-side, `/api/likes/[slug]`.

What's still open is the question the whole detour started with. I haven't resubmitted the URL to Trend Micro yet, so whether any of this changes the "Dangerous" / "Disease Vector" result is unconfirmed. Missing security headers are a plausible contributor to how an automated scanner scores a site, but **they're a guess, not a diagnosis Trend Micro has published.** If the classification doesn't move, the headers were still worth shipping on their own terms; they just weren't the whole answer to the question that started this.

## Summary

The path here wasn't the plan going in. Astro's stable, documented `security.csp` looked like the correct long-term answer for a static site, and **it took a real headless browser, not the framework's own build output, to find that it silently drops service-worker registration** for any script living outside the top-level layout. **The policy that shipped instead is hand-authored**, forces Astro's build to stop inlining anything a sitewide policy can't hash, and is checked two different ways for two different ways it could go wrong: a cheap CI script for a stale pinned hash, a local real-browser sweep for anything new. The original question, whether Trend Micro's "Dangerous" classification changes, is still open.

## References

- [MDN: Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP)
- [Astro docs: Content Security Policy configuration reference](https://docs.astro.build/en/reference/configuration-reference/)
- [withastro/astro issue #13996: experimentalStaticHeaders not working on Vercel](https://github.com/withastro/astro/issues/13996)
- [withastro/astro issue #14301: Experimental CSP not working with Astro image components](https://github.com/withastro/astro/issues/14301)
- [HSTS Preload List submission and removal, hstspreload.org](https://hstspreload.org/)
