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.

Nonce-based CSP request flow for flag bootstrap The server generates a nonce, attaches it to the CSP header and the inline bootstrap script tag, and the browser allows only scripts whose nonce matches the header value. 1. Generate nonce = random(16) 2. CSP header script-src 'nonce-…' 3. Bootstrap tag <script nonce="…"> 4. HTML response header + body together 5. Browser allows nonce match ✓ no-nonce blocked
The server generates one nonce per request, uses it in both the CSP header and the bootstrap script tag, and the browser enforces the match before executing any script.

Prerequisites

Prerequisites for a strict nonce-based CSP Four prerequisites: SSR with control over headers and body, the bootstrap already inline, a crypto nonce source, and a CSP reporting endpoint. SSR control headers + body Bootstrap inline script tag present Crypto nonce node:crypto / WebCrypto Report endpoint for Report-Only
Header-and-body control is the hard prerequisite — a nonce set in the body but not the header (or vice versa) blocks the very script you meant to allow.

Step-by-Step Procedure

The whole scheme rests on one invariant: the nonce in the CSP header and the nonce on the bootstrap tag must be the same per-request value, and the policy must omit 'unsafe-inline' or the nonce is ignored.

The nonce must match, and unsafe-inline must be absent One per-request nonce feeds both the CSP header's script-src and the bootstrap script tag; the browser executes the script only on a byte-for-byte match, and including unsafe-inline would make the nonce redundant. one nonce per request CSP header script-src 'nonce-...' bootstrap tag <script nonce="..."> match → runs unsafe-inline → nonce void
The same nonce must appear in both places, and 'unsafe-inline' must be absent — its presence tells the browser to ignore the nonce entirely.

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" into nonce="aB3xKp9m" (unchanged, fine) but also potentially turning + or = in the nonce into encoded forms. Use base64url encoding (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.

Three enforcement checks Confirm the header contains a nonce and no unsafe-inline, the bootstrap tag carries the matching nonce, and the console shows zero CSP violations while the live update succeeds. header nonce, no unsafe curl -I bootstrap tag nonce matches byte-for-byte console + network 0 violations live update 200/304
All three must agree: header nonce, tag nonce, and a clean console with a successful live-update request.

Gotchas & Edge Cases

A caching CDN breaks the per-request nonce A cached HTML body carries the original nonce while each fresh request generates a new CSP header nonce, so the two no longer match; mark bootstrap pages no-store or use a hash-based CSP. cached body nonce = A (stale) fresh header nonce = B (new) A ≠ B → blocked script rejected no-store or hash CSP the fix
Shared caching and per-request nonces are incompatible: keep the nonce-bearing HTML out of the shared cache, or switch that content to a hash-based CSP.

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.