
CSP Nonce Injection Through the Entire Stack: Express SSR → NGINX → Browser
Most developers know Content-Security-Policy exists. Fewer have actually shipped an enforcing policy. And almost nobody talks about the part that makes nonce-based CSP genuinely hard: making it work correctly across an SSR caching layer, a reverse proxy, and a browser that will block your entire page if a single <style> tag is missing its nonce.
I spent several weeks building a complete CSP nonce pipeline for codematters.johnbelthoff.com — an Angular SSR application served through an Express server behind an NGINX reverse proxy. The nonce has to be generated in Express, survive an HTML response cache with stampede protection, get communicated to NGINX through a response header, and end up in an enforcing CSP header that the browser actually validates. This post is the story of how that pipeline works, the bugs I hit along the way, and the security invariant that ties all three layers together.
In This Article
- Why Nonces Instead of Hashes or unsafe-inline
- The Architecture: Three Layers, One Nonce
- Layer 1: Express SSR — Generating and Injecting the Nonce
- The Cache Problem: Nonces Must Never Be Reused
- Stampede Coalescing: The Third Path
- Layer 2: NGINX — Reading the Nonce and Building the Policy
- Layer 3: The Browser — What Gets Enforced
- The Critters Problem: Why inlineCriticalCss Had to Go
- FOUC Prevention: Adding Your Own Inline Scripts Under Nonce CSP
- Testing the Entire Pipeline
- Lessons Learned
- Final Thoughts
Why Nonces Instead of Hashes or unsafe-inline
There are three ways to allow inline scripts and styles under CSP: 'unsafe-inline' (defeats the purpose), hashes (brittle with SSR because Angular generates different output per request), and nonces (a random token that must match between the CSP header and every inline element).
For an SSR application, nonces are the right choice. Angular’s server-side renderer generates inline <style> tags for component styles and a <script id="ng-state"> tag for transfer state. These are different on every page. Hashing them would require computing SHA-256 digests after rendering and injecting them into the CSP header — possible, but fragile. A nonce is simpler: generate one random value, stamp it on every inline element, and tell the browser to trust anything carrying that nonce.
The catch is that the nonce must be unique per response. If two users ever receive the same nonce, an attacker who observes one response can inject scripts into the other. That constraint is what makes caching interesting.
The Architecture: Three Layers, One Nonce
The request flow looks like this:
Browser → NGINX (reverse proxy) → Express (Angular SSR) → Browser
Each layer has a specific job:
- Express generates a cryptographic nonce, injects it into Angular’s rendering pipeline, stamps every inline
<style>and<script>tag, and exposes the nonce value via anX-CSP-Nonceresponse header. - NGINX reads the
X-CSP-Nonceheader from the upstream response, builds the fullContent-Security-Policyheader with'nonce-...'directives, strips theX-CSP-Nonceheader (so it never reaches the browser), and sends the enforcing policy. - The browser validates that every inline
<style>and<script>element carries anonceattribute matching the nonce declared in the CSP header. Anything without a matching nonce is blocked.
The security invariant that must hold across all three layers: the nonce in the HTML must exactly match the nonce in the CSP header, and that nonce must be unique to this specific response.
Layer 1: Express SSR — Generating and Injecting the Nonce
The Express server generates a 16-byte random nonce using Node’s crypto.randomBytes, base64-encodes it, and provides it to Angular through two mechanisms: the CSP_NONCE injection token (which Angular uses to stamp <style> tags) and the ngCspNonce attribute on the root <app-root> element (which Angular’s style serializer reads during SSR).
import { randomBytes } from 'node:crypto';
import { CSP_NONCE } from '@angular/core';
// Per-request nonce generation (inside the Express request handler)
const nonce = randomBytes(16).toString('base64');
res.setHeader('X-CSP-Nonce', nonce);
// Read index.html once at startup
const indexHtml = readFileSync(indexHtmlPath, 'utf-8');
// Inject nonce into the root element and replace any placeholders
const documentWithNonce = indexHtml
.replace('<app-root>', `<app-root ngCspNonce="${nonce}">`)
.replaceAll(NONCE_PLACEHOLDER, nonce);
const html = await engine.render({
bootstrap,
document: documentWithNonce,
url,
publicPath: browserDistFolder,
// Critters disabled — explained below
inlineCriticalCss: false,
providers: [
{ provide: SSR_RESPONSE_STATE, useValue: responseState },
{ provide: CSP_NONCE, useValue: nonce },
],
});
res.status(responseState.statusCode).send(html);Code language: TypeScript (typescript)
After Angular renders, every inline element in the HTML carries a nonce attribute:
<style nonce="k7Gf3xR2pQ9mLw4N+A==">
/* Angular component styles */
</style>
<script id="ng-state" nonce="k7Gf3xR2pQ9mLw4N+A==">
{"transferState": "..."}
</script>Code language: HTML, XML (xml)
The X-CSP-Nonce response header carries the same value for NGINX to read downstream.
The Cache Problem: Nonces Must Never Be Reused
This is where most implementations break.
The Express server has an LRU-based HTML cache with stampede protection. When a page is first rendered, the full HTML — including all those nonce-stamped inline elements — gets stored in the cache. Without intervention, every subsequent cache hit for that URL would return the same HTML with the same nonce, potentially for hours (the cache TTL is 3 hours in production).
A reused nonce is a security failure. Any attacker who observes one response can reuse the nonce to inject scripts that pass CSP validation for the entire cache TTL.
The fix is a placeholder-and-replace pattern. When storing HTML in the cache, the real nonce is swapped out for a static placeholder string. When serving from cache, a fresh nonce is generated and the placeholder is replaced:
const NONCE_PLACEHOLDER = '__SSR_CSP_NONCE_PLACEHOLDER__';
/**
* Replace the nonce placeholder in cached HTML with a fresh random nonce.
* Called on every cache hit and coalesced response to ensure unique nonces.
*/
function reNonce(html: string): { html: string; nonce: string } {
const nonce = randomBytes(16).toString('base64');
return { html: html.replaceAll(NONCE_PLACEHOLDER, nonce), nonce };
}Code language: TypeScript (typescript)
The cache store path replaces the real nonce with the placeholder before saving:
// After successful render — store with placeholder, not real nonce
if (cacheKey && responseState.statusCode === 200 && html) {
const placeholderHtml = html.replaceAll(
`nonce="${nonce}"`,
`nonce="${NONCE_PLACEHOLDER}"`
);
const entry: CachedHtmlEntry = {
html: placeholderHtml,
statusCode: responseState.statusCode,
gqlCacheHit,
};
htmlCache.set(cacheKey, entry);
}
// The fresh render sends the original html (with real nonce)
res.status(responseState.statusCode).send(html);Code language: TypeScript (typescript)
The cache hit path generates a fresh nonce every time:
if (cached) {
const { html, nonce } = reNonce(cached.html);
res.setHeader('X-SSR-Cache', 'HIT');
res.setHeader('X-CSP-Nonce', nonce);
res.status(cached.statusCode).send(html);
return;
}Code language: TypeScript (typescript)
This guarantees a unique nonce per response regardless of whether the HTML came from a fresh render or a cache hit. The NONCE_PLACEHOLDER string itself never appears in any response body — that’s tested.
Stampede Coalescing: The Third Path
There’s actually a third response path that’s easy to miss: stampede coalescing.
When multiple requests arrive for the same uncached URL simultaneously, only one triggers a render. The others wait on a shared Promise. When the render completes, the waiting requests receive the same cached entry — but each one still needs its own unique nonce.
if (inflight) {
const result = await inflight;
if (result) {
const { html, nonce } = reNonce(result.html);
res.setHeader('X-SSR-Cache', 'COALESCED');
res.setHeader('X-CSP-Nonce', nonce);
res.status(result.statusCode).send(html);
return;
}
}Code language: TypeScript (typescript)
Three response paths — fresh render, cache hit, and coalesced — and every single one must set a unique X-CSP-Nonce header that matches the nonces embedded in the HTML. Missing any one of these paths means NGINX builds a CSP header with the wrong nonce (or no nonce at all), and the browser blocks everything.
I found this bug in production. The cache hit and coalesced paths originally returned early without setting the X-CSP-Nonce header, causing NGINX to produce 'nonce-' (an empty nonce) in the CSP header. Every inline style on cached pages was blocked.
Layer 2: NGINX — Reading the Nonce and Building the Policy
NGINX’s job is to take the X-CSP-Nonce value from the upstream Express response and construct an enforcing CSP header. The key mechanism is NGINX’s map directive, which evaluates the upstream header and selects between two policy variants:
# Build CSP based on whether upstream returned X-CSP-Nonce
map $upstream_http_x_csp_nonce $cm_csp_enforcing {
"" "default-src 'self'; base-uri 'self'; object-src 'none';
frame-ancestors 'self'; form-action 'self';
script-src 'self'; style-src 'self'; font-src 'self';
img-src 'self' data: blob: https://secure.gravatar.com;
connect-src 'self'; media-src 'self';
worker-src 'self' blob:; report-uri /csp-report;";
default "default-src 'self'; base-uri 'self'; object-src 'none';
frame-ancestors 'self'; form-action 'self';
script-src 'self' 'nonce-$upstream_http_x_csp_nonce';
style-src 'self' 'nonce-$upstream_http_x_csp_nonce';
font-src 'self';
img-src 'self' data: blob: https://secure.gravatar.com;
connect-src 'self'; media-src 'self';
worker-src 'self' blob:; report-uri /csp-report;";
}Code language: Nginx (nginx)
When Express sends an X-CSP-Nonce header, NGINX interpolates it into both script-src and style-src. When the header is absent (say, for a static asset or error response), NGINX falls back to a strict policy with no nonce at all.
The location block then applies the policy and scrubs the internal headers:
location / {
# ... proxy configuration ...
proxy_pass http://$loopback_backend;
proxy_hide_header X-CSP-Nonce;
proxy_hide_header Content-Security-Policy;
proxy_hide_header Content-Security-Policy-Report-Only;
add_header Content-Security-Policy $cm_csp_enforcing always;
}Code language: Nginx (nginx)
Three proxy_hide_header directives are critical:
X-CSP-Nonce— The internal header must never reach the browser. Exposing the nonce in response headers widens the attack surface: reverse proxies often log response headers, and any log aggregation system or CDN edge server that captures this header provides the nonce to anyone with log access.Content-Security-Policy— Strip any CSP the upstream might set. NGINX is the single authority for CSP.Content-Security-Policy-Report-Only— Clean up any report-only headers from development.
Different location blocks get different CSP policies. Static assets (.css, .js, fonts, images) intentionally have no CSP — they’re fingerprinted, immutable, and adding CSP to them would just create noise. SVG files get a restrictive policy with script-src 'none' because SVGs can contain active content. API endpoints get default-src 'none'; sandbox;. Only the main location / (serving SSR HTML) gets the nonce-aware policy.
Layer 3: The Browser — What Gets Enforced
From the browser’s perspective, it receives an HTML document and a CSP header. The browser checks every inline <style> and <script> element against the policy:
Content-Security-Policy: ... script-src 'self' 'nonce-k7Gf3xR2pQ9mLw4N+A=='; style-src 'self' 'nonce-k7Gf3xR2pQ9mLw4N+A=='; ...
<style nonce="k7Gf3xR2pQ9mLw4N+A==">/* ✅ allowed */</style>
<style>/* ❌ blocked — no nonce */</style>
<script nonce="k7Gf3xR2pQ9mLw4N+A==">/* ✅ allowed */</script>
<script>alert('xss')</script><!-- ❌ blocked — no nonce -->Code language: HTML, XML (xml)
If an attacker injects a <script> tag through an XSS vulnerability, they don’t know the nonce (it changes every response), so the browser blocks it. That’s the entire point. The nonce is the proof that Express generated the inline content, not an attacker.
Any inline element without a matching nonce produces a CSP violation report sent to /csp-report, which the Express server logs for monitoring.
The Critters Problem: Why inlineCriticalCss Had to Go
Angular’s SSR pipeline includes Critters, a tool that extracts critical CSS and inlines it to improve First Contentful Paint. This sounds great until you realize that Critters injects <style> tags and onload handlers without CSP nonces.
Under an enforcing nonce-based CSP, Critters’ output gets blocked. The CSS doesn’t load. The page renders unstyled.
The fix was to disable Critters entirely — both at build time in angular.json and at SSR runtime:
const html = await engine.render({
bootstrap,
document: documentWithNonce,
url,
publicPath: browserDistFolder,
// Critters disabled: it injects <style> without CSP nonces,
// which our enforcing nonce-based CSP blocks.
// Re-enable if Critters adds nonce support.
inlineCriticalCss: false,
providers: [
{ provide: SSR_RESPONSE_STATE, useValue: responseState },
{ provide: CSP_NONCE, useValue: nonce },
],
});Code language: TypeScript (typescript)
This is a real tradeoff. Disabling critical CSS inlining means a slightly longer time to first render while stylesheets load. But a nonce-based CSP that actually enforces is worth more than a few milliseconds of render time. Security is not a feature you optimize away.
FOUC Prevention: Adding Your Own Inline Scripts Under Nonce CSP
With Critters disabled and nonce-based CSP enforcing, adding any new inline script requires careful integration with the nonce pipeline.
When I added dark mode support, the implementation required an inline script in <head> — before any stylesheets — to read the user’s theme preference from localStorage and apply the dark class to <html> before first paint. Without this script, dark mode users see a flash of the light theme on every page load.
The FOUC prevention script uses the nonce placeholder directly in index.html:
<script nonce="__SSR_CSP_NONCE_PLACEHOLDER__">
try {
var t = localStorage.getItem('theme-preference');
if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
} catch (e) {}
</script>Code language: HTML, XML (xml)
This works because the same replaceAll(NONCE_PLACEHOLDER, nonce) call that stamps the Angular-generated elements also stamps this script. On cache hits, reNonce() replaces the placeholder with a fresh nonce. The FOUC script’s nonce is always in sync with every other inline element — no separate mechanism needed.
The localStorage access is wrapped in try/catch because localStorage can throw in private browsing or restricted contexts. Resilience matters more than elegance for a script that runs before the framework initializes.
Testing the Entire Pipeline
The integration tests in server.integration.spec.ts verify every nonce invariant across all three response paths:
it('should produce unique nonces on cache hit responses (same URL)', async () => {
const res1 = await request(app).get('/csp-cache-nonce-test/');
const res2 = await request(app).get('/csp-cache-nonce-test/');
expect(res2.headers['x-ssr-cache']).toBe('HIT');
// Extract nonce from <style nonce="..."> in each response body
const nonceMatch1 = res1.text.match(/nonce="([^"]+)"/);
const nonceMatch2 = res2.text.match(/nonce="([^"]+)"/);
expect(nonceMatch1![1]).not.toBe(nonceMatch2![1]);
});
it('should set X-CSP-Nonce header matching HTML nonce on cache hit', async () => {
const res1 = await request(app).get('/csp-cache-header-match/');
const res2 = await request(app).get('/csp-cache-header-match/');
expect(res2.headers['x-ssr-cache']).toBe('HIT');
const headerNonce = res2.headers['x-csp-nonce'];
// The nonce in the HTML must match the header
expect(res2.text).toContain(`nonce="${headerNonce}"`);
// And it must differ from the first request's nonce
expect(headerNonce).not.toBe(res1.headers['x-csp-nonce']);
});Code language: TypeScript (typescript)
Additional tests cover:
- Placeholder never leaks: The
__SSR_CSP_NONCE_PLACEHOLDER__string never appears in any response body, on either MISS or HIT. - Base64 format validation: Nonces are always 24 characters of valid base64 (16 random bytes).
- TransferState nonce: The
<script id="ng-state">tag carries the same nonce as the<style>tags. - FOUC script nonce on cache HIT: The dark mode prevention script’s nonce matches
X-CSP-Nonceeven after nonce rotation. - No inline without nonce: Every
<style>and every executable<script>in the rendered HTML carries a nonce attribute.
it('should not contain any inline <style> or executable <script> without a nonce',
async () => {
const res = await request(app).get('/csp-no-inline-test/');
const allStyles = res.text.match(/<style[^>]*>/g) ?? [];
for (const tag of allStyles) {
expect(tag).toContain('nonce=');
}
const allScripts = res.text.match(/<script[^>]*>/g) ?? [];
for (const tag of allScripts) {
const isNonExecutable =
/type\s*=\s*["']application\/(json|ld\+json)["']/i.test(tag);
if (!isNonExecutable) {
expect(tag).toContain('nonce=');
}
}
});Code language: TypeScript (typescript)
These tests catch the exact class of bugs that broke the site in practice. The “header matching HTML on cache hit” test would have caught the original bug where cache hits returned early without setting X-CSP-Nonce.
Lessons Learned
A few practical takeaways from shipping nonce-based CSP in production:
Every response path needs the nonce. Fresh renders, cache hits, coalesced requests — miss any one of them and the browser blocks your page. Map out every code path that returns HTML and verify each one independently.
The nonce must never appear in response headers sent to the browser. NGINX’s proxy_hide_header X-CSP-Nonce is not optional. The header exists only as a communication channel between Express and NGINX. Leaking it to the browser (or to proxy logs) expands the attack surface.
Cache nonce placeholders, not nonces. Storing a real nonce in the cache means every cache hit reuses it. The placeholder-and-replace pattern is the only safe approach for SSR caching with nonce-based CSP.
Critters and nonce CSP are currently incompatible. If you need critical CSS inlining and nonce-based CSP, you’ll need to fork Critters or wait for nonce support. Disabling Critters is the honest tradeoff.
Test nonce parity, not just nonce presence. A nonce in the HTML and a different nonce in the CSP header is just as broken as no nonce at all. Tests should extract the nonce from both locations and assert equality.
replaceAll is your friend for nonce rotation. A single replaceAll(NONCE_PLACEHOLDER, nonce) call covers Angular’s <style> tags, the <script id="ng-state"> tag, the ngCspNonce attribute, and any custom inline scripts you add later. No separate tracking needed.
Use different CSP policies for different content types. API endpoints don’t need script-src. Static assets don’t need CSP at all. SVGs need script-src 'none' because they can contain active content. One policy does not fit all locations.
Final Thoughts
Nonce-based CSP is one of those features that sounds simple in a blog post and turns out to be a multi-layer coordination problem in practice. The nonce has to be generated in one process, survive a cache, get communicated across a process boundary to a reverse proxy, and end up in two places (the HTML and the CSP header) that the browser validates against each other.
Most of the bugs I encountered were not in the generation or injection — those are straightforward. They were in the seams between layers: the cache hit path that forgot to set the header, the coalesced response that reused the original nonce, the CSS inliner that didn’t know nonces existed.
The architecture I landed on — placeholder-based caching, reNonce() on every response path, NGINX map for conditional policies, and integration tests that verify parity across all paths — has been running in production since February 2026. CSP violation reports come in occasionally from browser extensions trying to inject scripts. They get blocked. That’s the whole point.
If you’re operating an SSR application behind a reverse proxy and you’ve been putting off CSP because it seems complicated — it is. But the alternative is 'unsafe-inline', which is another way of saying “CSP that doesn’t protect against the thing CSP was designed to protect against.” Take the time to build the pipeline correctly and test every path. Your future self will thank you when you see those violation reports getting blocked instead of executed.
More coding articles