CSP & Security Boundaries for Client Flags
This guide is part of the Frontend Integration & Client-Side Rendering series. Feature flags reach the browser via two paths: an inline bootstrap script embedded in the server-rendered HTML, and a live-update connection from the client SDK to the flag endpoint. Both paths cross a trust boundary — from your controlled server into an environment where any injected script can read page content. A well-configured Content Security Policy locks down both paths so flag data cannot be exfiltrated by XSS and flag delivery cannot be hijacked by a malicious script.
What This Guide Covers — and What It Does Not
This guide covers the Content Security Policy configuration for both delivery paths: the inline bootstrap script (governed by script-src) and the live-update connection from the client SDK (governed by connect-src). It also addresses the trust boundary principle — what information is safe to expose to the browser and what must stay server-side.
It does not cover how to build the bootstrap payload itself (see secure browser delivery), how to handle hydration mismatches (see SSR flag consistency), or the detailed step-by-step for a fully strict CSP (see setting a strict CSP for inlined flag bootstrap).
Prerequisites
crypto.randomUUID()/crypto.randomBytes()in the server request handler)- secure browser delivery)
- client SDK connects to
Core Concept: Two CSP Directives, Two Flag Delivery Paths
The inline bootstrap and script-src
The bootstrap pattern inlines the resolved flag set directly into the HTML so the SDK has valid state before any JavaScript loads. This is the most reliable way to prevent UI flicker, but it puts a <script> block in your HTML — exactly what a strict CSP aims to block.
The solution is a per-request nonce: a random value generated by the server, added to the <script> tag as a nonce="…" attribute, and listed in the CSP header as 'nonce-…'. The browser executes only scripts whose nonce matches the header value. An injected XSS payload has no way to know the nonce for that request.
Why not use a hash instead of a nonce? A hash (
'sha256-…') works for truly static scripts whose content never changes. The flag bootstrap is different for every request (different flag values per user), so a nonce is the correct choice.
The live update connection and connect-src
After the bootstrap, the client SDK opens a connection to the flag endpoint to receive updates. Without an explicit connect-src directive, a default default-src 'self' policy blocks connections to any external flag service (or even a first-party subdomain like flags.your-app.com). This fails silently in some browsers — the SDK never updates and users see stale variants indefinitely.
The fix is narrow: allow exactly the flag endpoint origin, nothing more:
Content-Security-Policy: connect-src 'self' https://flags.your-app.com
The trust boundary principle
The CSP configuration enforces a technical boundary, but the underlying rule is conceptual: the browser only receives resolved variants, never targeting rules or evaluation context. No CSP directive can protect a rule object that you accidentally included in the payload — it would be readable to any script on the page, including third-party analytics. Keep the payload to {flagKey: variant} pairs.
This also applies to PII masking: the evaluation context (user ID, email, tenant, plan tier) that the server uses to resolve flags must never appear in the bootstrap payload. Strip it during server-side evaluation, before serialization.
Step-by-Step Implementation
Step 1 — Generate a per-request nonce
Generate a fresh nonce in the server request handler, before rendering. The nonce must be unguessable and unique per request.
// server/middleware/cspNonce.ts
import { randomBytes } from 'node:crypto';
export function generateNonce(): string {
return randomBytes(16).toString('base64url');
// Example output: "ZoKkp8rKH1mE4v2BjA3q9g"
}
Store the nonce in the request context so both the HTML renderer (to add it to the <script> tag) and the response header builder (to add it to the CSP) can access the same value within a single request lifecycle.
Step 2 — Apply script-src with the nonce
Emit the CSP header before the response body. Include the nonce in script-src and exclude 'unsafe-inline' entirely.
// server/middleware/cspHeaders.ts
export function buildCspHeader(nonce: string, flagEndpointOrigin: string): string {
return [
`script-src 'self' 'nonce-${nonce}'`,
// No 'unsafe-inline' — the nonce covers the bootstrap
`connect-src 'self' ${flagEndpointOrigin}`,
`default-src 'self'`,
`style-src 'self' 'unsafe-inline'`, // adjust to your style setup
`img-src 'self' data:`,
`object-src 'none'`,
`base-uri 'self'`,
`frame-ancestors 'none'`,
].join('; ');
}
Apply it in your request pipeline:
// Express example
app.use((req, res, next) => {
const nonce = generateNonce();
res.locals.cspNonce = nonce;
res.setHeader(
'Content-Security-Policy',
buildCspHeader(nonce, 'https://flags.your-app.com'),
);
next();
});
Step 3 — Attach the nonce to the bootstrap <script> tag
The HTML renderer must use the same nonce value it passed to the CSP header.
// server/renderFlags.ts
export function renderBootstrapTag(
flagData: string,
sig: string,
nonce: string,
): string {
// Escape the data to prevent JSON containing </script> from breaking the page
const safe = flagData.replace(/<\/script>/gi, '<\\/script>');
return `<script nonce="${nonce}" id="__flag_bootstrap__" data-sig="${sig}">${safe}</script>`;
}
Pitfall: Never build the CSP header in the template layer where the nonce is embedded in HTML. Build it in the request middleware layer, pass the nonce down, and let the template receive it as a value. If the header is set after the body starts flushing, it has no effect in most frameworks.
Step 4 — Configure connect-src for the live flag endpoint
Add the flag service origin to connect-src. If you use Server-Sent Events for streaming updates, connect-src covers that too — SSE connections go through connect-src, not script-src.
// Verify the connect-src covers your actual endpoint scheme and host
// Good: 'https://flags.your-app.com' (exact origin)
// Avoid: 'https://*.your-app.com' (too broad — allows all subdomains)
// Avoid: '*' (defeats the purpose)
If you self-host flagd, the origin is typically http://flagd.internal in cluster traffic — add it to connect-src with the correct scheme.
Step 5 — Deploy in Report-Only mode first
Before enforcing, emit Content-Security-Policy-Report-Only with a report-uri or report-to endpoint. Review violations for at least 24 hours across your traffic mix.
res.setHeader(
'Content-Security-Policy-Report-Only',
buildCspHeader(nonce, 'https://flags.your-app.com') +
`; report-to csp-endpoint`,
);
res.setHeader(
'Reporting-Endpoints',
'csp-endpoint="https://your-app.com/api/csp-reports"',
);
Common violations you will see before enforcement: a vendor analytics tag injecting its own <script>, a style attribute on a dynamically generated element, or a third-party font CDN not yet in style-src. Fix each one before switching to enforcement mode.
Verification & Testing
After switching to enforcement:
# Confirm the CSP header is present and contains nonce + no unsafe-inline
curl -s -I https://your-app.example/dashboard \
| grep -i 'content-security-policy'
# Confirm the bootstrap script tag has a nonce attribute
curl -s https://your-app.example/dashboard \
| grep '__flag_bootstrap__'
# Run Chrome with CSP reporting enabled and check the console for violations
# Open DevTools → Console → filter for "Content Security Policy"
Also verify with the browser’s built-in CSP validator: open the page, open DevTools → Console — any blocked script or connection appears as a CSP error with the directive name and blocked URL.
Troubleshooting & FAQ
The client SDK reports it cannot connect to the flag endpoint after I added the CSP.
The flag endpoint origin is missing from connect-src. Check the exact scheme and hostname the SDK uses (http vs https, port if non-standard) and ensure it matches the connect-src value exactly. Wildcard subdomains (*.your-app.com) work but are broader than needed.
My bootstrap script is blocked even though I added a nonce.
The most likely cause is a nonce mismatch: the nonce in the CSP header and the nonce in the <script nonce="…"> attribute were generated in different parts of the request pipeline. Ensure both read from the same request-scoped value. Also check that the nonce value is not URL-encoded in one place and not the other — use plain base64url throughout.
Some replicas serve a cached response without a nonce.
A cached HTML response reuses the original nonce, but CDN or reverse-proxy caching means the browser may get a nonce that matches no current CSP header (each request generates a new nonce). Solution: mark flag-bootstrap pages Cache-Control: private, no-store at the CDN, or use a static hash-based CSP if you can make the bootstrap content deterministic.
Does using type="application/json" for the bootstrap element avoid the nonce requirement?
Yes — a <script type="application/json"> element is a data block, not an executable script. The browser does not run it, so script-src does not apply. If you use this pattern (reading the element via document.getElementById), you still need connect-src for the live update, but you can skip the nonce entirely for the bootstrap element. See the strict CSP how-to for both approaches.
Performance & Scale Considerations
Nonce generation is cheap (16 bytes of entropy from the OS CSPRNG). The overhead is negligible compared to the flag evaluation itself. The CSP header adds roughly 100–200 bytes per response — also negligible. The larger concern is cache interaction: any response with a per-request nonce in the body cannot be served from a shared CDN cache without leaking nonces across users. Design your caching strategy to separate the flag-bootstrapped HTML (private, short TTL) from static assets (public, long TTL).