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.
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-checkis still tautological. It proves the policy doesn’t break the palette or scroll reveals under Playwright. It cannot prove header/HTML agreement. Onlyverify-prod-cspagainst 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.
Related logs
The Database Emptied Itself Twice: Forensics on Shared Credentials and Truncate-Capable Scripts
Twice in 2026 the TweakDeals database was emptied from under the site — in August, 2,544 products at 10:32 UTC were zero by 11:05. Forensics, guards, and the prebuild cache that saved the site.
RDS Certificate Subject-Name Mismatch: Binding Let's Encrypt Across All Four Roles
Twingate routed RDS traffic fine — then RD Gateway rejected every session on certificate subject-name mismatch. The fix: win-acme DNS-01 plus one PowerShell script binding all four RDS roles every 60 days.