Technology

CSP Done Right: How I Use Nonces, Hashes, and report-to to Tame Inline Scripts on WordPress and Laravel

So there I was, staring at a client’s WordPress dashboard at 1 a.m., wondering why the homepage was randomly breaking only for some visitors. The site still loaded, but half the buttons didn’t respond. Classic ghost bug. After a quick sweep, it turned out a harmless-looking plugin slipped in a new inline script. On its own, no big deal. But combined with a strict Content Security Policy (CSP), it was blocked right out of the gate. That night was my reminder: CSP is powerful, but it’s also unforgiving if you don’t set it up with a plan—especially on platforms that love inline scripts like WordPress and, in a different way, Laravel.

If you’ve ever had a page work in staging and then die in production, or you’ve tried to stamp out XSS while keeping marketing scripts happy, you’ll feel right at home here. In this guide, I’ll walk you through how I set up CSP with nonces and hashes, make sense of report-to (plus a report-uri fallback), and gently tame those inline scripts in WordPress and Laravel without losing your mind—or your analytics. We’ll talk real-world gotchas, practical config snippets, and a rollout plan you can actually ship.

İçindekiler

The Real Reason CSP Matters (and Why It Bites First in WordPress)

I like to think of CSP as the bouncer at the club. Without it, anyone who looks vaguely like JavaScript can stroll right in and start handing out dodgy links. With it, you have a guest list: only scripts from these places, in these formats, under these conditions. Clean and calm.

But here’s the thing—WordPress and many themes/plugins sprinkle inline scripts like confetti. A tiny initialization here, a wp_localize_script JSON blob there, maybe an inline jQuery snippet in the footer. Laravel’s Blade templates aren’t immune either; you might be echoing variables into scripts or dropping small inline helpers during prototyping. All of these run headfirst into CSP the moment you remove 'unsafe-inline', which you should remove if you want CSP to actually protect you from XSS.

In my experience, the first time you enable a strict CSP, the console lights up like a Christmas tree. That’s not failure—that’s visibility. CSP shows you where the inline landmines are so you can migrate them to safer patterns: nonces for per-request whitelisting, hashes for static inline blocks, and better yet, external scripts with no inline at all. Once you’ve tamed the inline chaos, you get a policy that actually holds the line.

CSP in Plain English: The Bouncer’s Rules

Let’s keep it simple. A CSP header is just a list of directives. default-src is your catch-all, script-src is specifically for JavaScript, style-src for CSS, and so on. You can point to specific origins (like your domain and a CDN), and you can add special tokens that unlock safer inline behavior.

The usual troublemaker is 'unsafe-inline'. It’s convenient—everything inline runs—but it more or less disables CSP’s protection against inline XSS. If a malicious input ends up in your page, inline JS executes. With CSP done right, we ditch 'unsafe-inline' and replace it with nonces or hashes. The policy can also say, “trust this nonce, and anything that script loads,” using 'strict-dynamic', which is a powerful friend when you’re moving away from older patterns.

Think of it like this: nonces are like unique wristbands you hand out at the door for each request; hashes are like a signature you’ve pre-approved because you know this exact block of code is safe. Both let you keep the bouncer tough without turning away your friends.

Nonces vs Hashes: When to Use What (and Why strict-dynamic Helps)

Nonces for dynamic, hashes for static

Here’s my rule of thumb. If an inline script is generated per request or it might change a lot (think localized data, server-injected variables), use a nonce. You generate a random base64 nonce on the server for every request, add it to the CSP header (something like script-src 'nonce-abc123'), and then tag each inline script with nonce="abc123". The browser checks the nonce in the tag against the header and allows it if they match.

If a block is static and tiny (for example, a well-known initialization snippet that hardly ever changes), you can use a hash instead. Hashes require calculating a SHA-256 (or 384/512) of the exact inline content. If you change even one character, the hash breaks and the browser blocks it. Perfect for little constants; annoying for anything you edit often.

Why I often add strict-dynamic

'strict-dynamic' lets trusted scripts (ones allowed via a nonce or hash) load other scripts, and those child scripts inherit trust—even if they come from a new origin that’s not listed. This is lovely when you have a loader script that pulls the rest of your app. With 'strict-dynamic', you’re telling the browser, “if the first one is trusted, let it bootstrap the rest.”

A baseline policy I like

For many sites, this is a clean starting point when you’re ready to enforce:

Content-Security-Policy: 
  default-src 'self'; 
  script-src 'self' 'nonce-REPLACE_ME' 'strict-dynamic' https:; 
  style-src 'self' 'nonce-REPLACE_ME' https:; 
  img-src 'self' data: https:; 
  font-src 'self' https: data:; 
  connect-src 'self' https:; 
  frame-ancestors 'self'; 
  base-uri 'self'; 
  object-src 'none'; 
  report-to csp-endpoint; 
  report-uri https://report.example.com/csp

You’ll notice I used both report-to and report-uri. Browser support has shifted around over the years, so I still include both. That way, you get reports in more places while the ecosystem continues to settle.

WordPress: Taming Inline Scripts Without Breaking the Theme

The game plan

WordPress loves inline scripts. Plugins add wp_localize_script blobs, themes sprinkle helper inits, and someone somewhere always left an onclick in a template. The trick isn’t to banish them in one day. The trick is steady, friendly discipline: add nonces everywhere you can, migrate inline handlers to addEventListener, and move long inline code into enqueued files.

Generate a nonce per request and send CSP headers

I like dropping this into a small MU-plugin or a site plugin so it’s always loaded:

<?php
// wp-content/mu-plugins/csp.php
if (!defined('ABSPATH')) { exit; }

add_action('init', function () {
    // Per-request nonce
    $nonce = base64_encode(random_bytes(16));
    $GLOBALS['csp_nonce'] = $nonce;
});

add_action('send_headers', function () {
    if (headers_sent()) { return; }
    $nonce = isset($GLOBALS['csp_nonce']) ? $GLOBALS['csp_nonce'] : '';

    // Define your reporting endpoints
    // Keep report-uri as a fallback while report-to/reporting endpoints evolve
    $reportUri = 'https://report.example.com/csp';

    // Note: Some installs use a reverse proxy/CDN for headers; you can move this there if you prefer.
    $csp = "default-src 'self'; " .
           "script-src 'self' 'nonce-{$nonce}' 'strict-dynamic' https:; " .
           "style-src 'self' 'nonce-{$nonce}' https:; " .
           "img-src 'self' data: https:; " .
           "font-src 'self' https: data:; " .
           "connect-src 'self' https:; " .
           "frame-ancestors 'self'; base-uri 'self'; object-src 'none'; " .
           "report-to csp-endpoint; report-uri {$reportUri}";

    header('Content-Security-Policy: ' . $csp);

    // Optional: Report-Only during rollout
    // header('Content-Security-Policy-Report-Only: ' . $csp);

    // Define reporting endpoints for browsers that support this
    header("Report-To: {"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"{$reportUri}"}]}");
    header("Reporting-Endpoints: csp-endpoint="{$reportUri}"");
});

// Add nonce to enqueued scripts
add_filter('script_loader_tag', function ($tag) {
    if (is_admin()) { return $tag; }
    if (!empty($GLOBALS['csp_nonce'])) {
        // Insert nonce attribute if not present
        if (false === strpos($tag, 'nonce=')) {
            $tag = preg_replace('/<script(s+)/', '<script nonce="' . esc_attr($GLOBALS['csp_nonce']) . '" $1', $tag, 1);
        }
    }
    return $tag;
}, 10, 1);

// Add nonce to enqueued styles
add_filter('style_loader_tag', function ($tag) {
    if (is_admin()) { return $tag; }
    if (!empty($GLOBALS['csp_nonce'])) {
        if (false === strpos($tag, 'nonce=')) {
            $tag = preg_replace('/<link(s+)/', '<link nonce="' . esc_attr($GLOBALS['csp_nonce']) . '" $1', $tag, 1);
        }
    }
    return $tag;
}, 10, 1);

// Add nonce to inline scripts/styles if your WP version exposes these filters
// These filters exist in modern WP versions and make life easier
add_filter('wp_print_inline_script_tag', function ($tag) {
    if (!empty($GLOBALS['csp_nonce']) && false === strpos($tag, 'nonce=')) {
        $tag = str_replace('<script', '<script nonce="' . esc_attr($GLOBALS['csp_nonce']) . '"', $tag);
    }
    return $tag;
}, 10, 1);

add_filter('wp_print_inline_style_tag', function ($tag) {
    if (!empty($GLOBALS['csp_nonce']) && false === strpos($tag, 'nonce=')) {
        $tag = str_replace('<style', '<style nonce="' . esc_attr($GLOBALS['csp_nonce']) . '"', $tag);
    }
    return $tag;
}, 10, 1);

This tiny helper covers a lot of ground. It generates a per-request nonce, sets a CSP header, and injects nonce into both external and inline tags—so your wp_localize_script and other inline bits stay allowed without blowing the door open with 'unsafe-inline'. If your theme or plugins output raw inline tags outside the enqueue system, you might have to patch those templates or nudge developers to use wp_enqueue_script properly.

Moving away from inline handlers

Inline attributes like onclick, onload, and friends are kryptonite for CSP. The healthier pattern is a short external script that attaches listeners after DOMContentLoaded. For example:

// main.js
window.addEventListener('DOMContentLoaded', () => {
  document.querySelectorAll('[data-action="do-thing"]').forEach(btn => {
    btn.addEventListener('click', (e) => {
      // your logic here
    });
  });
});

Then in your HTML, use data- attributes, not inline JS:

<button data-action="do-thing">Click me</button>

That tiny change lets you remove inline handlers while keeping your CSP clean. Over time, those small refactors add up to a site that’s both safer and easier to reason about.

About third-party tags

Third-party scripts can be tricky with strict CSP—especially tag managers or A/B testing tools that love to inject inline code. If you absolutely must use them, prefer loader tags that work with external files and nonces. Keep a short allowlist in script-src for the domains you trust, and consider staging-only testing before you push any new vendor script to production. When I’m shipping bigger changes, I often roll CSP out in Report-Only first, similar to how I describe canaries in a friendly guide to canary deploys with weighted routing and safe rollbacks.

Laravel: Middleware, Blade, and a Clean Nonce Flow

Middleware that just works

Laravel gives you a beautiful place to generate a nonce and share it with your views. I usually add a middleware like this:

php artisan make:middleware AddCspHeaders
<?php
// app/Http/Middleware/AddCspHeaders.php

namespace AppHttpMiddleware;

use Closure;
use IlluminateHttpRequest;
use SymfonyComponentHttpFoundationResponse;

class AddCspHeaders
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        // Generate per-request nonce
        $nonce = base64_encode(random_bytes(16));
        app()->instance('csp-nonce', $nonce);

        // Build CSP
        $reportUri = config('security.csp_report_uri', 'https://report.example.com/csp');
        $csp = "default-src 'self'; " .
               "script-src 'self' 'nonce-{$nonce}' 'strict-dynamic' https:; " .
               "style-src 'self' 'nonce-{$nonce}' https:; " .
               "img-src 'self' data: https:; font-src 'self' https: data:; " .
               "connect-src 'self' https:; frame-ancestors 'self'; base-uri 'self'; object-src 'none'; " .
               "report-to csp-endpoint; report-uri {$reportUri}";

        // Set headers (swap to Report-Only during rollout if you prefer)
        $response->headers->set('Content-Security-Policy', $csp, false);
        $response->headers->set('Report-To', json_encode([
            'group' => 'csp-endpoint',
            'max_age' => 10886400,
            'endpoints' => [['url' => $reportUri]],
        ]), false);
        $response->headers->set('Reporting-Endpoints', "csp-endpoint="{$reportUri}"", false);

        return $response;
    }
}

Register it in your HTTP kernel so it runs on web requests:

// app/Http/Kernel.php
protected $middlewareGroups = [
    'web' => [
        // ...
        AppHttpMiddlewareAddCspHeaders::class,
    ],
];

Using the nonce in Blade

Now it’s easy to tag your scripts and styles in Blade templates:

<?php // app/helpers.php or a Service Provider
if (!function_exists('csp_nonce')) {
    function csp_nonce(): string { return app('csp-nonce') ?? ''; }
}
<script nonce="{{ csp_nonce() }}" src="{{ mix('js/app.js') }}" defer></script>
<script nonce="{{ csp_nonce() }}">
    window.App = { csrf: '{{ csrf_token() }}' };
</script>
<style nonce="{{ csp_nonce() }}">/* tiny critical CSS */</style>

If you’re using Vite, you can customize the tags produced by the helper so the nonce is attached. One approach is to wrap the helper in your own function that prints tags with nonce. Another is to publish the Vite configuration and adjust the tag rendering. Keep the nonce on every script or style tag you output from Blade if you rely on inline bits.

Dealing with vendor scripts

Similar to WordPress, try to keep third-party scripts external and on a short allowlist. If you absolutely need an inline snippet for a vendor, consider hashing it once it’s stable, or wrap it in a small external file that’s loaded with your nonce. The smaller the inline surface, the safer everything feels.

Collecting CSP Reports: report-to, report-uri, and the Gentle Rollout

Why reports matter

Rolling out CSP is like adjusting a soundboard. You’re going to mute a few tracks you didn’t expect, and the reports help you find which ones. The browser will send you a JSON payload when something is blocked, including the URL, the directive, and the source. Hook these up before enforcement and you’ll save yourself days of guessing.

Use Report-Only first, then enforce

Start with Content-Security-Policy-Report-Only and ship the exact policy you hope to enforce. Give it a few days, watch the reports, and fix or allow what makes sense. This is the safest, least dramatic way to go live. When the noise settles, switch to Content-Security-Policy for enforcement.

report-to and report-uri, together

The reporting landscape has changed a few times, and different browsers have different preferences. I still define report-to and keep report-uri as a fallback so I capture the widest set of reports. The gist looks like this:

Content-Security-Policy-Report-Only: ... report-to csp-endpoint; report-uri https://report.example.com/csp
Report-To: {"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"https://report.example.com/csp"}]}
Reporting-Endpoints: csp-endpoint="https://report.example.com/csp"

You can roll your own endpoint in Laravel (a simple route that logs JSON is fine), but I often use a hosted service to keep things tidy. Tools like managed CSP reporting dashboards make it easy to spot patterns and silence the noise.

Analyze, adjust, and roll out gradually

I like to ship CSP the way I ship other risky infra changes—softly. If you’re on Nginx, you can set a response header on a small percentage of traffic first, then increase. If you’re behind a CDN, a ruleset with a conditional header is perfect. If you want a friendly playbook for gradual rollouts in general, I’ve written about canary deploys with Nginx weighted routing and safe rollbacks—the same thinking applies to security headers.

From Inline Chaos to Calm: A Practical Migration Path

1) Start with a clear, minimal policy in Report-Only

Don’t try to solve everything day one. Start with a clean baseline, include your nonce and only the origins you truly need. Add 'strict-dynamic' if your app uses a loader script. Flip to Report-Only and watch what breaks in the console and in reports.

2) Enqueue and externalize

On WordPress, move all inline blocks that are longer than a few lines into proper enqueued files. Keep wp_localize_script but rely on your nonce to legitimize the inline JSON. On Laravel, drop helpers into external JS modules and pass data to the DOM via data- attributes or a single nonced inline JSON blob you trust.

3) Replace inline handlers with event listeners

Pull out onclick, onload, and friends. Add listeners in your JS bundle. It’s a boring refactor that pays you back forever. Your CSP gets simpler, and your code becomes easier to maintain across themes or template changes.

4) Hash the rare, stable inline block

If there’s a tiny initialization snippet that never changes, hash it once and move on. This keeps your policy tight without introducing a complicated build step. I use SHA-256 most of the time. Make sure the hash matches the exact contents of the inline block—no extra spaces or line breaks.

5) Document your choices

Write down why each domain is on your allowlist, which scripts require a nonce, and how new scripts should be added. Future you (and your teammates) will thank you. I keep this in the repo alongside the header config so reviews are easy.

Nginx/Apache/CDN: Where to Set the Headers

You can set CSP headers at the application layer or at the edge. I usually set them in-app when using nonces, because the nonce needs to be per-request and shared with templates. That said, if your CSP is stable and doesn’t use nonces or you prefer hashes, pushing it to Nginx or a CDN can be clean and fast.

Nginx example (nonce placeholder)

It’s common to have the app emit the actual header (so the nonce is correct) and use Nginx only for Report-To or cache-control tweaks. If you must attach CSP at Nginx, consider passing the nonce from the app to Nginx via a response header and interpolating it in a sub_filter. It’s a bit more advanced, but it can work in a pinch.

# Pseudocode-ish, illustration only
add_header Report-To '{"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"https://report.example.com/csp"}]}' always;
add_header Reporting-Endpoints 'csp-endpoint="https://report.example.com/csp"' always;

# For CSP, prefer application layer when using nonces

If you’re running behind a CDN like Cloudflare, remember that some features inject their own scripts (think Rocket Loader). Either disable those or explicitly allow them. If you want a safe way to publish apps with a CDN without opening ports, I wrote a practical walkthrough on publishing behind Cloudflare Tunnel with Zero-Trust, and a deeper piece on protecting your origin with mTLS—both pair nicely with a strong CSP.

Common Gotchas and the Friendly Fixes

“It works locally but not in production”

Local builds often load fewer scripts and skip CDN features. Production might have extra analytics, A/B testing, or a CDN optimizer that injects a helper. Grep your response for <script> tags you didn’t expect and expand your allowlist narrowly or disable the injector.

“My inline JSON broke”

If you put JSON inside a <script> tag for configuration, it still counts as an inline script. Give it a nonce. If you want to be very tidy, use a MIME type like application/json and still add the nonce. Keep it predictable and the browser is happy.

“I need eval for a vendor”

'unsafe-eval' is a slippery slope. If you can avoid it, do. If a vendor absolutely requires it, try to isolate it to a separate page or route, or contain it in a sandboxed iframe with its own policy. Don’t add it site-wide if you can help it.

“Blocking looks random”

It’s not random, it’s just subtle. Use the browser console—CSP errors are explicit about which directive failed. Then run your header through the CSP Evaluator to catch the gotchas I still miss when I’m in a hurry. Nine times out of ten, one rogue inline tag is missing a nonce.

“WordPress core or a plugin still injects inline”

Many modern WordPress filters can add nonce to inline tags as shown earlier. If a plugin bypasses those APIs and prints raw HTML, nudge it into the enqueue system or open a tiny PR. As a last resort, you can pre-allow a stable inline block via a hash while you work with the vendor.

Security Is a Team Sport: Pair CSP with Good Hygiene

CSP is a brilliant safety net, but it’s not the whole story. Keep your runtimes and plugins tidy, patch regularly, and ship boring upgrades. If you’re running WordPress or Laravel on modern PHP, performance and security just feel better. I wrote a calm checklist for that journey here: the PHP 8.x upgrade checklist for WordPress/Laravel. Pair that with a hardened origin and smart edge rules and you’ve removed entire classes of headaches before they start.

Wrap-Up: Your Calm CSP Playbook

I still remember the first project where I flipped CSP to enforce and everything looked like it shattered. It hadn’t. CSP was doing me a favor—shining a light on all the places code was taking shortcuts. Once we added nonces, hashed one tiny snippet, and moved a few helpers into proper files, the site felt cleaner. Fewer mystery bugs. No surprise popups. A quiet console is a beautiful thing.

If you’re on WordPress, add a per-request nonce, push it into every script and style tag you control, and chip away at inline handlers. On Laravel, a small middleware plus a Blade helper gets you 80% there in an afternoon. For both, start with Report-Only, collect reports via report-to and a report-uri fallback, and ship the rest gradually. When in doubt, let the reports guide you instead of guesses.

And remember, security blends best with reliability. If you want to roll out a stricter CSP the same way you would a traffic shift, take a look at how I handle canary deployments on a VPS. If your stack lives behind a CDN, put it on a zero-trust footing with Cloudflare Tunnel and protect the origin itself with Authenticated Origin Pulls and mTLS. The combination of a well-tuned CSP and a calm deployment rhythm can turn even a busy WordPress or Laravel app into a trustworthy, low-drama experience.

Hope this was helpful! If you try this playbook, I’d love to hear what you bumped into and how you solved it. See you in the next post.

Helpful References I Like

When I need a quick sanity check, I hop into the MDN docs for CSP and the Evaluator tool. They’re simple and to the point:

Frequently Asked Questions

Great question! Use nonces for dynamic or frequently changing inline code, and hashes for tiny, stable snippets that rarely change. You can mix both in the same policy. In practice, I lean on nonces for WordPress/Laravel and hash the occasional small init that never changes.

Both if you can. Browser support has shifted over time, so I still include report-to and keep report-uri as a fallback. Start in Report-Only, collect a few days of data, adjust, then move to enforcement. A hosted dashboard makes the noise easier to digest.

Replace inline handlers with addEventListener in an external script and target elements with data- attributes. In WordPress, encourage plugins to use the enqueue APIs so you can inject nonces; in Laravel, move helpers into your JS bundle and keep inline JSON minimal and nonced.