Setting a Strict CSP for Inlined Flag Bootstrap
This how-to is part of CSP & Security Boundaries for Client Flags. The problem is a tension between two valid goals: you want to inline the resolved flag set into the server-rendered HTML for a flicker-free first paint (see preventing UI flicker and secure browser delivery), but a strict Content Security Policy blocks any inline script that lacks an explicit permission. The fix is a per-request nonce: a random value that authorizes only the scripts you generated, not any script an attacker injects.
Prerequisites
<script>tag (see secure browser delivery)node:cryptononce generation (or the Web Crypto API in edge runtimes)connect-src
Step-by-Step Procedure
Step 1 — Generate a cryptographically random nonce per request
The nonce must be generated fresh for every HTTP request — never reused, never hardcoded. A 16-byte random value gives 128 bits of entropy, which is more than enough.
// server/nonce.ts
import { randomBytes } from 'node:crypto';
/**
* Returns a base64url-encoded nonce safe for use in HTML attributes
* and CSP header values without additional escaping.
*/
export function createNonce(): string {
return randomBytes(16).toString('base64url');
// Example: "aB3xKp9mRzLwE2vN7qY0cg"
}
Store the nonce in the request object (not a module-level variable, not a process global). In Next.js, use headers() from next/headers; in Express, store it in res.locals; in Fastify, in request.context.
// Express middleware — runs before any route handler
import { createNonce } from './nonce';
app.use((req, res, next) => {
res.locals.cspNonce = createNonce();
next();
});
Pitfall: Using a module-level nonce variable means every concurrent request shares the same nonce. An attacker who reads the nonce from the HTML can inject scripts for the lifetime of the nonce — which is now unlimited. Always scope the nonce to the request.
Step 2 — Attach the nonce to the inline bootstrap script tag
The bootstrap script tag must carry a nonce attribute whose value matches the nonce in the CSP header. The values are compared byte-for-byte by the browser.
// server/bootstrap.ts
export function renderBootstrapScript(
flagPayload: string,
sig: string,
nonce: string,
): string {
// Escape any </script> sequences to prevent HTML injection
const safe = flagPayload.replace(/<\/script>/gi, '<\\/script>');
return [
`<script`,
` nonce="${nonce}"`,
` id="__flag_bootstrap__"`,
` data-sig="${sig}"`,
`>`,
safe,
`</script>`,
].join('\n');
}
Inject this tag immediately after <head> in the HTML template so it runs before any module script that might try to read flags.
Pitfall: Some template engines HTML-encode attribute values, turning
nonce="aB3xKp9m"intononce="aB3xKp9m"(unchanged, fine) but also potentially turning+or=in the nonce into encoded forms. Usebase64urlencoding (which uses-and_instead of+and/) to avoid this. Base64url output contains only[A-Za-z0-9_-]— safe in any HTML attribute.
Step 3 — Emit the script-src 'nonce-…' CSP header
Set the Content-Security-Policy response header using the same nonce. Critically: do not include 'unsafe-inline'. If you include both a nonce and 'unsafe-inline', browsers treat the nonce as redundant and allow all inline scripts — defeating the entire point.
// server/csp.ts
export function buildFlagBootstrapCsp(nonce: string, flagOrigin: string): string {
const directives = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}'`,
// NO 'unsafe-inline' here — nonce takes precedence in modern browsers
// but older browsers fall back to unsafe-inline if it's present
`connect-src 'self' ${flagOrigin}`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: blob:`,
`font-src 'self'`,
`object-src 'none'`,
`base-uri 'self'`,
`frame-ancestors 'none'`,
];
return directives.join('; ');
}
// In Express:
app.use((req, res, next) => {
const nonce = res.locals.cspNonce as string;
res.setHeader(
'Content-Security-Policy',
buildFlagBootstrapCsp(nonce, 'https://flags.your-app.com'),
);
next();
});
Pitfall: Setting the CSP header after the response body starts streaming has no effect. Middleware that sets headers must run before the route handler begins writing the response. In frameworks that use streaming SSR, you may need to set headers in a separate layer that flushes before the body.
Step 4 — Add connect-src for the live flag endpoint
The client SDK opens a network connection to receive flag updates after the bootstrap. This connection is governed by connect-src, not script-src. Without it, the live-update fetch is silently blocked in strict CSP mode.
// Add the flag service origin to connect-src (already in the template above)
// For SSE streaming endpoints, connect-src covers EventSource connections too.
// For WebSocket connections, you also need: ws://flags.your-app.com (or wss://)
const directives = [
`connect-src 'self' https://flags.your-app.com`,
// If using WebSocket for flag streaming:
// `connect-src 'self' https://flags.your-app.com wss://flags.your-app.com`,
];
Verify the exact origin: if the SDK connects to https://flags.your-app.com/api/v1/stream, the connect-src origin is https://flags.your-app.com (scheme + host + port if non-standard, no path).
Step 5 — Deploy with Content-Security-Policy-Report-Only first
Switch to enforcement only after you have reviewed at least one full business day of CSP reports. Report-Only sends violation reports to your endpoint without blocking anything.
// Switch from enforcement to report-only by changing the header name:
res.setHeader(
'Content-Security-Policy-Report-Only',
buildFlagBootstrapCsp(nonce, 'https://flags.your-app.com') +
'; report-to csp-violations',
);
res.setHeader(
'Reporting-Endpoints',
'csp-violations="https://your-app.com/api/csp-reports"',
);
// Simple Express handler to log reports:
app.post('/api/csp-reports', (req, res) => {
const { body } = req;
console.warn('CSP violation:', JSON.stringify(body));
res.status(204).end();
});
Common sources of violations during the Report-Only window: browser extensions injecting scripts, analytics tags lacking nonces, and third-party widgets. Resolve each by either adding their nonces (if you render them server-side) or allowing their hashes (if their content is static).
Verification
After deploying in Report-Only mode and fixing violations, switch to enforcement and verify:
# 1. Confirm the CSP header is present and contains a nonce, not unsafe-inline
curl -s -I https://your-app.example/dashboard \
| grep -i content-security-policy
# Expected output contains:
# script-src 'self' 'nonce-<base64url>'
# and does NOT contain:
# 'unsafe-inline'
# 2. Confirm the bootstrap tag carries the matching nonce
curl -s https://your-app.example/dashboard \
| grep -o 'nonce="[^"]*"' | head -3
# 3. Open the browser, load the page, open DevTools → Console
# There should be zero "Content Security Policy" errors
# The flag bootstrap should initialize without errors
Also confirm the live SDK update works: in DevTools → Network, filter for your flag endpoint URL and verify the request succeeds (200 or 304). A blocked connect-src shows as a network error with a CSP violation in the console.
Gotchas & Edge Cases
- Nonce in meta tag: Some frameworks allow setting CSP via a
<meta http-equiv="Content-Security-Policy">tag instead of a header. This does not work forframe-ancestorsorreport-uri, and for nonces it is less secure because the meta tag is in the DOM and potentially accessible to injected scripts before parsing is complete. Use the HTTP header. - Incremental streaming SSR: If your framework flushes the
<head>before completing the body, the CSP header must be set before the first flush. In Next.js App Router withgenerateStaticParamsor edge middleware, attach the nonce to a request header and read it in the route handler. - Safari and nonces: Safari ≥ 15.4 supports nonces correctly. Older versions require both the nonce and a hash fallback for the same script to work cross-browser. If you need Safari < 15.4 support, consider using
type="application/json"for the bootstrap (noscript-srcneeded) and a separate hashed initialization script.
Troubleshooting & FAQ
The bootstrap runs in Report-Only mode but is blocked after switching to enforcement.
A common cause: the nonce in the HTML body and the nonce in the CSP header are set from different values in your codebase — perhaps one reads res.locals.cspNonce and another reads a different variable. Add logging at the point where the header is set and at the point where the nonce="…" attribute is rendered, and compare the values in a single request’s server logs.
I see a CSP violation for the bootstrap script even though the nonce looks correct.
Check for whitespace: some template engines add a leading or trailing space inside the nonce="…" attribute value. The browser compares the attribute value and the CSP nonce token byte-for-byte, including whitespace. Trim the nonce value when rendering the attribute.
How does this interact with a CDN that caches the HTML?
If the CDN caches the rendered HTML, all requests served from cache share the same nonce embedded in the body, but each uncached request generates a new nonce in the CSP header — causing a mismatch. The solution is to mark bootstrapped pages Cache-Control: private, no-store at the CDN, or to use a hash-based CSP instead of a nonce for any content you need to cache publicly.