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.
The reason flags deserve their own CSP treatment — rather than riding on whatever policy the rest of the app already ships — is that the bootstrap script is one of the very few inline scripts most modern applications still emit. Bundlers eliminated inline scripts years ago; frameworks moved event handlers into JavaScript modules. The flag bootstrap is the exception because it must run before any bundle loads, which means it is often the single directive standing between you and a fully hash-or-nonce CSP with no 'unsafe-inline' anywhere. Get this one script right and the rest of the policy tightens almost for free. Get it wrong and you either leave 'unsafe-inline' in script-src — which neuters the entire policy against XSS — or you break the bootstrap and reintroduce the UI flicker the inline payload was meant to eliminate.
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
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.
There is one important subtlety in how the nonce protects you. The nonce is not a secret you are hiding from an attacker who can already read the DOM — a script that runs on the page can trivially read the nonce attribute off any element. What the nonce actually defends against is injection: a reflected or stored XSS payload is written into the page as a string by the server, and at the moment the server serializes that string it does not know — and cannot know — the nonce it will mint for the next request. Modern browsers reinforce this by hiding the nonce from the DOM entirely once parsing completes: reading scriptElement.nonce returns the value, but scriptElement.getAttribute('nonce') returns an empty string, and the attribute never appears in innerHTML serialization. That gap is deliberate — it stops an attacker from scraping a live nonce out of the page and reusing it in an injected tag within the same request.
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 “fails silently” behaviour is worth dwelling on, because it is the single most confusing symptom teams hit. When connect-src blocks a fetch, the promise rejects with a TypeError that looks identical to a network outage — there is no CSP-specific error object handed to your code. When it blocks an EventSource (SSE), the connection simply enters the CLOSED state and the SDK’s reconnect loop keeps firing without ever succeeding. Neither path throws anything a generic try/catch will flag as a policy problem, so from the SDK’s perspective the flag service is “down.” The only reliable signal is the browser console and the CSP violation report — which is exactly why Report-Only mode (step 5) matters so much. If you ship enforcement without watching reports first, a missing connect-src entry looks like an intermittent backend incident and sends you debugging the wrong system entirely.
One more origin subtlety: connect-src matches the origin the request is made to, not any redirect target. If flags.your-app.com issues a 307 to a regional host like flags-eu.your-app.com, the redirect target must also appear in connect-src or the follow-up request is blocked. Prefer resolving the regional endpoint on the server and handing the client a single stable origin, so the browser never sees a cross-origin redirect it has to be pre-authorized for.
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.
The reason this matters more than it first appears is that the browser payload is permanently readable by everything on the page — CSP does nothing to compartmentalize data between scripts once they are allowed to run. A third-party session-replay tool, an analytics snippet, a chat widget, or a browser extension all see the same window and the same DOM as your own code. If you leak a targeting rule such as plan == "enterprise" AND seats > 50 => variant "beta", you have handed every one of those parties a map of your segmentation logic, and possibly a way to self-select into a treatment by spoofing context on a subsequent request. If you leak the raw context, you have exported PII into an uncontrolled surface where your data-retention and consent guarantees no longer hold. Treat the bootstrap payload as the exact equivalent of a public API response: assume an adversary reads every byte, and expose only the resolved decision. The rules, the reasons, the fractional-rollout buckets, and the context all stay on the server where the server-side SDK evaluated them.
A useful mental test before serializing: could you paste this JSON into a public gist without any security review flagging it? If the answer is no, something in the payload belongs on the other side of the boundary.
Step-by-Step Implementation
The five steps wire the policy end to end: generate a nonce, put it in script-src, attach it to the bootstrap tag, allow the flag origin in connect-src, and roll out in Report-Only before enforcing.
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.
Two properties are non-negotiable here. First, entropy: use the OS cryptographic RNG (randomBytes or randomUUID), never Math.random(). A nonce derived from a predictable source is a nonce an attacker can precompute, which collapses the entire guarantee — the CSP working group specifies at least 128 bits of entropy for exactly this reason, and 16 bytes gives you that. Second, freshness: generate a new nonce for every response, never one per process, per user, or per session. A nonce reused across requests becomes a stable value an injected script can learn once and replay indefinitely; at that point it is functionally equivalent to 'unsafe-inline'. Do not cache the nonce, do not memoize the middleware, and do not let a framework’s response-caching layer capture a nonce-bearing body — the caching interaction is the most common way a “unique” nonce quietly becomes a shared one in production.
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 on internal network traffic — add it to connect-src with the correct scheme. In practice the browser almost never talks to flagd directly; it talks to an edge or gateway host that proxies to flagd on the internal network, so the origin you list in connect-src should be that public gateway (https://flags.your-app.com), not the internal service name. Listing an internal .internal or .svc.local host in a browser-facing CSP is a sign the architecture has leaked private network topology into the client — the browser cannot resolve those names anyway.
A subtle scheme trap catches teams running a strict policy: if your page is served over HTTPS and you also set upgrade-insecure-requests or the browser’s mixed-content blocker is active, an http:// entry in connect-src is silently upgraded or refused. Always list the flag endpoint with the same scheme the browser will actually use — https:// for any production origin — and confirm there is no http:// fallback baked into the SDK configuration.
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.
Interpret the report volume, not just its presence. A handful of script-src violations from browser extensions injecting content is expected and unactionable — you cannot allowlist every user’s extensions, and those violations are contained to that user’s session rather than a hole in your policy. What you are hunting for is a systematic pattern: the same directive, the same blocked URL, appearing across many sessions, which indicates a legitimate first-party or contracted third-party resource you forgot to allow. Budget at least a full business cycle in Report-Only — 24 hours is a floor, but a week captures weekday-versus-weekend traffic, marketing tags that only fire on campaign pages, and the less-visited routes that rarely see traffic. Enforce only once the report stream has flattened to background noise you understand. Keep the reporting endpoint live after enforcement too: a violation that appears the day you ship a new vendor tag is the fastest signal that someone added a script without updating the policy.
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. A second common cause is a framework or streaming SSR layer that flushes the <head> (or the whole document) before your middleware sets the header, so the header the browser receives carries a different, later nonce than the one already written into the body. If the tag is correct but still blocked, capture the raw response with curl and compare the header nonce byte-for-byte against the attribute — they must be identical strings, and any surrounding quote or whitespace difference counts as a mismatch.
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. The trade-off is that a type="application/json" block cannot initialize the SDK by itself — some small amount of real script still has to read the element and hand its contents to the SDK, and that reader script needs the nonce (or a hash). Most teams end up with one tiny nonce-covered reader plus a nonce-free JSON data block, which is a clean split.
Should I add 'strict-dynamic' to script-src, and does it affect the flag bootstrap?
'strict-dynamic' tells the browser to trust scripts loaded by an already-trusted (nonce- or hash-approved) script, while ignoring host allowlists. It is excellent for modern bundlers where one nonce-approved entry script loads the rest of the app dynamically. It does not change how the flag bootstrap is authorized — the bootstrap still needs its own nonce because nothing trusted has loaded it yet; it is written directly into the HTML by the server. The one thing to watch: once 'strict-dynamic' is present, any plain host source in script-src (like a CDN URL) is ignored by supporting browsers, so make sure the flag SDK bundle is itself loaded by a nonce-approved script rather than relying on a host allowlist entry that will now be dropped.
Can I use a single static CSP header in a CDN or _headers file instead of generating it per request?
Only if you drop nonces and switch to hashes. A static header cannot contain a fresh per-request nonce — the value has to be minted by the server for each response and mirrored into the body, which a static file or edge rule cannot do. If your bootstrap content is deterministic (for example a type="application/json" data block plus a fixed reader script), compute the SHA-256 hashes of those scripts once at build time and list them as 'sha256-…' sources in the static header. You keep the CDN-cacheable, no-server-logic simplicity at the cost of the bootstrap payload no longer being able to vary its script text per request — the JSON data can still vary because it lives in a non-executed data block.
Does the CSP protect against a malicious flag endpoint sending a hostile payload?
No — CSP governs which origins the browser may contact, not what those origins return. If flags.your-app.com is compromised and starts returning malicious variant values, connect-src will happily allow the connection because the origin is on the allowlist. Defense against a hostile payload is a separate layer: sign the payload server-side and verify the signature before the SDK trusts it (see securely passing flags to the browser), and validate that every value the client applies is one of the variants your code actually expects. Treat connect-src as an anti-exfiltration and anti-hijack control, not an integrity control.
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).
This constraint has an architectural consequence worth naming: the per-request nonce effectively forces the flag-bootstrapped document to be dynamically rendered at request time, which is fine for authenticated dashboards but at odds with fully static or edge-cached marketing pages. If you need CSP and shared caching on the same route, the escape hatch is the hash-based approach from the static-header FAQ above — deterministic bootstrap scripts, 'sha256-…' sources, and the varying data confined to a non-executed type="application/json" block. That combination is cacheable because nothing in the executable script text changes per user, only the JSON data does, and JSON data is not governed by script-src. Choose per route: nonce for personalized dynamic pages, hash for cacheable ones. Trying to force one strategy across every page type is where most CSP-plus-flag rollouts stall.