Nonce-based Content-Security-Policy (SEC-11)
Nonce-based Content-Security-Policy (SEC-11)
Released in v0.1.108
The Sanctions Monitor platform enforces a strict Content-Security-Policy (CSP) that removes 'unsafe-inline' and 'unsafe-eval' from script-src. This is achieved through a per-request cryptographic nonce that flows from the Edge middleware through every server component that emits an inline <script> tag.
Architecture overview
Middleware (Edge)
└─ crypto.getRandomValues() → 128-bit nonce
└─ CSP header: script-src 'self' 'nonce-<value>' ← no unsafe-inline, no unsafe-eval
└─ x-nonce request header forwarded to SSR
Server Components
└─ headers().get("x-nonce")
└─ <script nonce={nonce}> on all inline scripts
└─ ThemeProvider nonce={nonce} ← colour-scheme injection
└─ JsonLd nonce={nonce} ← structured data scripts
Step-by-step flow
- Nonce generation — On every incoming request the Edge middleware calls
crypto.getRandomValues()to produce a 128-bit (16-byte) cryptographically random value, Base64-encoded to a 24-character string. - CSP header — The nonce is embedded in the
Content-Security-Policyresponse header as'nonce-<value>'insidescript-src. No'unsafe-inline'or'unsafe-eval'tokens are present. - Header forwarding — The nonce is also set as the
x-noncerequest header so Next.js server components can read it during SSR. - Server component propagation — Each server component root calls
headers().get("x-nonce")and stamps the value onto every<script nonce={nonce}>tag it renders. - Browser enforcement — The browser compares each script's
nonceattribute against the value in the CSP header and blocks any script that does not match.
CSP directives
| Directive | Value | Notes |
|---|---|---|
default-src | 'self' | Restrictive baseline |
script-src | 'self' 'nonce-<value>' | No unsafe-inline, no unsafe-eval |
style-src | 'self' 'unsafe-inline' | Required by Tailwind v4 runtime injection |
img-src | 'self' data: blob: https: | Allows Base64 and external images |
font-src | 'self' data: | Allows embedded fonts |
connect-src | 'self' https: | Allows HTTPS fetch/XHR |
frame-ancestors | 'self' + trusted origins | Restricts embedding to known hosts |
upgrade-insecure-requests | — | Upgrades HTTP sub-resources to HTTPS |
Trusted frame-ancestors origins
https://agent-os-saa-s-factory.vercel.apphttps://jugg.aihttps://*.vercel.apphttp://localhost:3000
Components that carry the nonce
JsonLd component
The JsonLd component accepts an optional nonce prop:
// src/components/json-ld.tsx
export function JsonLd({
schema,
nonce,
}: {
schema: Record<string, unknown> | Record<string, unknown>[];
nonce?: string;
}) {
return (
<script
type="application/ld+json"
nonce={nonce}
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}
Although <script type="application/ld+json"> is technically structured data rather than executable script, the CSP specification is ambiguous on this point. Chrome 95+ and Firefox 93+ have begun enforcing script-src rules on these tags. Carrying the nonce is zero-cost and future-proofs the component.
Root layout
// src/app/layout.tsx
const nonce = headersList.get("x-nonce") ?? "";
// ...
<JsonLd schema={organizationSchema} nonce={nonce} />
Home page
// src/app/page.tsx
export default async function Home() {
const headersList = await headers();
const nonce = headersList.get("x-nonce") ?? "";
// ...
<JsonLd schema={softwareApplicationSchema} nonce={nonce} />
<JsonLd schema={faqSchema} nonce={nonce} />
}
What is NOT covered (known limitations)
style-src 'unsafe-inline'
Tailwind v4 injects utility class styles at runtime, which requires 'unsafe-inline' to remain in style-src. Eliminating this requires either:
- Switching to a hash-based
style-srcwhere each injected style block is pre-hashed at build time, or - Producing a fully static CSS output so no runtime injection occurs.
This is tracked as a separate future enhancement and is outside the scope of SEC-11.
Testing
The file tests/lib/csp.test.ts (141 lines, new in v0.1.108) provides a unit test suite covering:
| Test | Description |
|---|---|
| Nonce embedding | buildCsp() embeds the supplied nonce in script-src |
No unsafe-eval | script-src does not contain 'unsafe-eval' |
No unsafe-inline | script-src does not contain 'unsafe-inline' |
'self' present | script-src always includes 'self' |
default-src | Set to 'self' |
upgrade-insecure-requests | Present in all policies |
frame-ancestors | Contains trusted origins; no wildcard allow-all |
connect-src | Allows 'self' and https: |
img-src | Allows data: and blob: URIs |
font-src | Allows data: URIs |
| Nonce entropy | 128-bit Base64-encoded, 24 characters |
| Nonce uniqueness | 100 consecutive calls all produce unique values |
Tests use Vitest/Node and operate on pure functions — no Edge runtime or Next.js internals are required.
Why experimental.nonce is not used
Older Next.js documentation references an experimental.nonce config option. This option does not exist in the current @types/next. The equivalent behaviour is achieved by:
- Reading
headers().get("x-nonce")in each server component root. - Stamping the value onto
<script nonce={nonce}>tags manually.
This is the approach used throughout the codebase and is documented in next.config.ts.