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

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" 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.

Gotchas & Edge Cases

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.