Advanced Content Security Policy: Nonces, Strict CSP, and Reporting
A deep dive into modern Content Security Policy—moving from allowlist-based CSP to nonce and hash-based strict CSP, understanding strict-dynamic, CSP Level 3 features, configuring report-to vs report-uri, and using Report-Only mode to safely deploy restrictive policies.
Why Basic CSP Fails
Content Security Policy (CSP) was introduced to mitigate XSS by defining which sources of content a browser should execute or load. In theory, a policy that only allows scripts from your own domain should prevent injected scripts from executing.
In practice, allowlist-based CSP is notoriously difficult to maintain and easy to bypass. Real-world analysis by researchers at Google found that 99.34% of CSP policies that rely on allowlists can be bypassed. The reasons:
CDN-hosted scripts are exploitable: A policy that includes https://www.googleapis.com trusts everything served from that domain. If an attacker can influence a parameter in a Google API call to inject a script callback, they have bypassed CSP.
unsafe-inline is widespread: Many developers add unsafe-inline to script-src when their framework or analytics tool requires inline scripts, defeating the entire purpose of the policy for XSS mitigation.
Wildcard sources: *.company.com trusts any subdomain. If one subdomain is compromised or has an open redirect, CSP is bypassed.
Domain trust bloat: Over time, more analytics tools, A/B testing frameworks, chat widgets, and monitoring scripts are added to the allowlist. Each new trusted domain expands the attack surface.
The solution is strict CSP using nonces or hashes instead of domain-based allowlists.
Nonce-Based CSP
A nonce (number used once) is a random, base64-encoded value that is:
- Generated fresh on every HTTP response
- Included in the CSP header
- Added as an attribute to every
<script>and<style>tag that should be allowed to execute
The browser only executes inline scripts that carry a valid nonce matching the one in the CSP header. Injected scripts (from XSS) cannot know the nonce—it is different for every response—so they are blocked even if unsafe-inline is effectively replaced by the nonce.
Generating Nonces in Next.js
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'nonce-${nonce}'`,
`img-src 'self' data: https:`,
`font-src 'self'`,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors 'none'`,
`upgrade-insecure-requests`,
].join('; ');
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
const response = NextResponse.next({
request: { headers: requestHeaders },
});
response.headers.set('Content-Security-Policy', csp);
return response;
}
Read the nonce in your layout component and apply it to all script tags:
// app/layout.tsx
import { headers } from 'next/headers';
import Script from 'next/script';
export default async function RootLayout({ children }) {
const nonce = headers().get('x-nonce') ?? '';
return (
<html>
<head>
<Script
nonce={nonce}
strategy="beforeInteractive"
src="/analytics.js"
/>
</head>
<body>{children}</body>
</html>
);
}
Next.js's <Script> component automatically applies the nonce attribute when provided.
strict-dynamic: Making Nonces Practical
One problem with nonces is that they do not apply to dynamically created scripts. If a legitimate first-party script (loaded with a nonce) dynamically creates another <script> element to load a module, the dynamically created script lacks a nonce and is blocked.
strict-dynamic addresses this. When included in script-src, it:
- Allows scripts loaded by a nonced/trusted script to also load scripts
- Disregards any domain-based allowlists in the policy (they become irrelevant once strict-dynamic is present)
The full strict CSP directive:
script-src 'strict-dynamic' 'nonce-{NONCE}' 'unsafe-inline' https:;
Wait—unsafe-inline in a strict CSP? This is deliberate for backward compatibility. In CSP Level 3, when nonce- is present, unsafe-inline is ignored by browsers that support nonces. For older browsers that do not understand nonces, unsafe-inline provides a graceful fallback. Similarly, https: provides a fallback for very old browsers. In a modern browser, the effective policy is just 'strict-dynamic' 'nonce-{NONCE}'.
This pattern is recommended by Google:
Content-Security-Policy:
script-src 'strict-dynamic' 'nonce-{NONCE}' 'unsafe-inline' https:;
object-src 'none';
base-uri 'none';
Hash-Based CSP
When you cannot regenerate a nonce per response (static sites, cached pages), hash-based CSP provides a similar guarantee for inline scripts that are truly static.
A hash is computed over the content of the inline script, and only scripts whose content matches the declared hash are executed:
<!-- The exact script content -->
<script>
window.analytics = { userId: null };
</script>
Compute the SHA-256 hash:
echo -n "window.analytics = { userId: null };" | openssl dgst -sha256 -binary | openssl base64
# Outputs: abc123def456...
CSP header:
script-src 'sha256-abc123def456...'
The script executes only if its content exactly matches the hash—including whitespace. A single added space breaks the match.
Hash-based CSP is most useful for:
- Static sites where inline scripts never change
- Specific scripts that must be inline for performance (critical rendering path)
- Combining with nonces:
script-src 'nonce-{NONCE}' 'sha256-{HASH}'
CSP Level 3 Features
trusted-types
Trusted Types is a CSP Level 3 feature that prevents DOM XSS by requiring that string-to-HTML assignments go through a Trusted Type policy. This eliminates entire classes of DOM XSS vulnerabilities.
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default
// Without Trusted Types (blocked)
document.innerHTML = userInput; // Blocked!
// With Trusted Types (requires a policy)
const policy = trustedTypes.createPolicy('default', {
createHTML: (input) => DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: true })
});
document.innerHTML = policy.createHTML(userInput); // Allowed, sanitized
Browser support is broad (Chromium-based browsers, Firefox with flag). For new applications, this is a valuable defense-in-depth layer.
navigate-to
Restricts the URLs the page can navigate to. Useful for pages that should only navigate to specific destinations:
Content-Security-Policy: navigate-to https://yourapp.com https://billing.yourapp.com
script-src-elem and script-src-attr
CSP Level 3 separates script-src into separate directives for <script> elements and inline event handlers (onclick=...):
script-src-elem 'nonce-{NONCE}' 'strict-dynamic';
script-src-attr 'none'; # Completely block inline event handlers
Blocking all inline event handlers (onclick, onload, etc.) with script-src-attr 'none' eliminates a class of XSS payloads that do not use <script> tags.
report-uri vs report-to
CSP violation reporting sends JSON reports to a specified endpoint when the browser blocks something.
Legacy: report-uri
Content-Security-Policy: ...; report-uri https://yourapp.com/csp-reports
The report-uri directive is deprecated but still widely used because of broad browser support. It sends reports as individual POST requests with JSON bodies.
Modern: report-to
report-to uses the Reporting API (a standardized mechanism for browser reporting, not just CSP). Reports are batched and sent more efficiently:
Reporting-Endpoints: csp-endpoint="https://yourapp.com/csp-reports"
Content-Security-Policy: ...; report-to csp-endpoint
Note: report-to and report-uri can coexist for maximum compatibility during the transition period.
Receiving and Processing Reports
CSP violation reports arrive as POST requests with Content-Type: application/csp-report:
{
"csp-report": {
"document-uri": "https://yourapp.com/page",
"referrer": "",
"violated-directive": "script-src",
"effective-directive": "script-src-elem",
"original-policy": "script-src 'nonce-abc123' 'strict-dynamic'...",
"disposition": "enforce",
"blocked-uri": "inline",
"status-code": 200,
"script-sample": "alert('xss')"
}
}
Set up a lightweight reporting endpoint. The blocked-uri: "inline" with a recognizable script-sample indicates an XSS injection attempt. blocked-uri: "eval" indicates an eval() call being blocked (often from legitimate third-party scripts that need to be refactored).
Commercial report aggregators (Report URI, Sentry) handle report ingestion and provide dashboards for analyzing violation trends.
Report-Only Mode: Safe Deployment
Deploying a new strict CSP directly to production risks breaking legitimate functionality. Content-Security-Policy-Report-Only sends the same reports as an enforced policy without actually blocking anything:
Content-Security-Policy-Report-Only:
script-src 'strict-dynamic' 'nonce-{NONCE}' 'unsafe-inline' https:;
object-src 'none';
base-uri 'none';
report-to csp-endpoint
The workflow for deploying a new CSP:
- Deploy in Report-Only against your production traffic
- Monitor violations for 1–2 weeks. Distinguish between:
- Legitimate violations: your own scripts blocked by the new policy (need to add nonces)
- Third-party violations: analytics/chat scripts blocked (evaluate whether they need to be inline or can load from external files)
- Attack violations:
blocked-uri: inlinewith suspiciousscript-samplevalues
- Fix legitimate violations by converting to nonced scripts or loading externally
- Switch to enforcement once the violation report is clean
- Keep reporting active: ongoing CSP reports are an early warning system for new XSS vulnerabilities and third-party script compromises
Practical CSP for Next.js Applications
A complete CSP for a typical Next.js application with analytics and fonts:
Content-Security-Policy:
default-src 'self';
script-src 'strict-dynamic' 'nonce-{NONCE}' 'unsafe-inline' https:;
style-src 'self' 'nonce-{NONCE}' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https: blob:;
connect-src 'self' https://api.yourapp.com https://vitals.vercel-insights.com;
media-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
report-to csp-endpoint;
Start with Report-Only, observe for two weeks, clean up violations, then enforce. The result is a policy that blocks nearly all XSS injection attempts while remaining compatible with your application's legitimate functionality.