Skip to main content
System.Return('/blog')
4 min read

Nonces End to End: generate, build, inject, finalize, verify

Five scripts carry one UUID from mint to production header — scripts/generate-csp.mjs, inject-nonce.mjs, finalize-csp.mjs, csp-check.mjs, verify-prod-csp.mjs. Two production incidents drew the map; the pipeline diagram in this post is the repo's own chain.

A per-build nonce is useless unless the value in the HTML and the value in the header are the same value. This repo mints exactly one UUID per build and carries it through five scripts. Two production incidents drew the map, and both were invisible to the rest of the suite. That invisibility is the whole reason this post exists.

Nonce pipeline: generate-csp mints one UUID into .csp-nonce, inject-nonce stamps built HTML, finalize-csp stamps headers, verify-prod-csp asserts what Vercel serves

1. scripts/generate-csp.mjs: mint

Runs before astro build (package.json: "build": "node scripts/generate-csp.mjs && astro build && node scripts/inject-nonce.mjs"). Generates randomUUID(), writes it to .csp-nonce for the post-build step, and logs only NONCE.slice(0,8)... (the full value never hits logs). Locally only the nonce file is created; vercel.json stays clean.

The script’s header comment records the CSP state this repo converged on: script-src hardened with strict-dynamic plus nonce and no unsafe-inline, while style-src deliberately stays 'self' + 'unsafe-inline' with no nonce. Per CSP3 a nonce in that directive makes 'unsafe-inline' ignored, which blocked every Shiki code block (style="color:#79B8FF" per token span is a build-time attribute no nonce can cover).

2. astro build: emit plain tags

The build emits plain <script>/<style> tags plus two outputs that both need stamping: dist/client/ (local preview) and .vercel/output/static/ (what gets deployed).

3. scripts/inject-nonce.mjs: stamp the HTML

Reads .csp-nonce, walks both output dirs, adds nonce="{NONCE}" to every opening <script> and <style> tag in every .html file. Current scale: 166 HTML files per build (see the Phase 2A build log). Keeps .csp-nonce on disk, because the deploy job’s finalize step needs it.

4. scripts/finalize-csp.mjs: stamp the headers

The deploy job runs this on the same .csp-nonce the build produced. It never generates a nonce: a fresh randomUUID() here would mint a second value and re-create the exact header/HTML mismatch it exists to prevent. It substitutes the nonce into the header routes (via scripts/lib/vercel-headers.mjs), re-reads from disk after writing, asserts the final state, and cross-checks the built HTML when present.

The reason it exists: vercel deploy without --prebuilt parses vercel.json for headers before running the build command, so generate-csp.mjs’s in-build rewrite landed too late, inside an ephemeral container. Production shipped the committed nonce-REPLACED_AT_BUILD placeholder while the HTML carried a real UUID, and strict-dynamic (which makes 'self' irrelevant) blocked every script. Prebuilt deploys read headers from the stamped workspace, so the ordering race cannot happen. The full incident writeup is the CSP outage post.

5. scripts/verify-prod-csp.mjs + csp-check.mjs: assert, honestly labeled

csp-check.mjs (CI verify stage) synthesizes its own header from the nonce it finds in the built HTML. The repo’s own comments call it tautological and structurally incapable of catching a header/HTML mismatch. assert-csp-nonce-clean asserts the opposite direction (committed vercel.json holds only the placeholder). The one check that reads what Vercel actually serves is verify-prod-csp.mjs: it polls the live URL for roughly 90 seconds (edge cache), asserts the full header set (CSP plus X-Frame-Options, Permissions-Policy, Cross-Origin-*, and HSTS with includeSubDomains; preload), and fails on the literal placeholder.

The second incident that motivated it: switching to vercel deploy --prebuilt made .vercel/output/config.json the sole header source, so all nine vercel.json headers vanished at once. A single-header check would not have surfaced the scope.

Current served header (from vercel.json):

Content-Security-Policy: default-src 'self'; script-src 'self'
'strict-dynamic' 'nonce-{UUID}' https://va.vercel-scripts.com;
style-src 'self' 'unsafe-inline'; ... report-uri /api/csp-report

Honest limitations

  • Per-build nonces rather than per-request. A fresh UUID per deploy means every visitor in a deploy window shares one nonce. That matches a static-export architecture (no server to mint per-request values) and is strictly better than the committed placeholder, but it is not the per-request rotation a server-rendered app would run.
  • csp-check is still tautological. It proves the policy doesn’t break the palette or scroll reveals under Playwright. It cannot prove header/HTML agreement. Only verify-prod-csp against production does that, and it runs post-deploy rather than as a gate.
  • style-src 'unsafe-inline' remains. Bounded (style injection only, no script execution) but present. Dropping Shiki for hand-themed Prism is the documented alternative, accepted as a visual change not yet made.

Each deploy without the one-UUID-through-five-scripts discipline is a fresh chance to reintroduce the mismatch local CI structurally cannot see: production serving a committed nonce-REPLACED_AT_BUILD while the HTML carries a real UUID, with strict-dynamic blocking every script. csp-check synthesizes its own header, so only verify-prod-csp against the live URL closes the loop. The incident that drew this map is the CSP outage postmortem, and the edge-served static-shell architecture that makes per-build nonces the right lifetime is in the Cloudflare Workers notes.

Share this log: Share on X Share on LinkedIn Share on Reddit

Related logs

↑↓ navigate Tab complete Enter open Esc close View all →