{} CodeLift

Implementing Content-Security-Policy in Laravel

Content-Security-Policy (CSP) is the last defense layer that stops XSS damage in the browser. The server declares "these are the only scripts allowed to run and resources allowed to load on this page," and the browser rejects everything el…

Pub 2026-04-19 Verified 2026-04-19 Upd 2026-08-12

Verification environment

  • Laravel 13.x
  • PHP 8.5

Content-Security-Policy (CSP) is the last defense layer that stops XSS damage in the browser. The server declares "these are the only scripts allowed to run and resources allowed to load on this page," and the browser rejects everything else.

This article is an implementation guide for adding CSP to a Laravel app properly. It collects what CodeLift learned while verifying three Laravel starter kits (React / Vue / Livewire), written as a fork-independent reference.

Applied examples in real forks: Hardening the shared starter-kit backend (introducing CSP) / Livewire nonce-based CSP (dropping 'unsafe-inline')

What CSP defends against

When an XSS succeeds, the attacker's script runs in the legitimate page's origin. Stealing session cookies, tampering with forms, showing fake input screens — anything is possible.

Input sanitization and output escaping are the first defense, but one missed spot in a template breaks them. CSP is the second arrow: even if a script is injected, the browser won't execute it. Defense in depth — do both input handling and CSP.

Key directives

CSP is declared via the Content-Security-Policy response header. The directives that matter in practice:

Directive Role
default-src Fallback for unspecified directives. Start with 'self'
script-src Where scripts may come from. The most important one
style-src Where stylesheets / inline styles may come from
img-src Where images may come from. Decide whether to allow data: / blob:
connect-src Where fetch / XHR / WebSocket may connect
frame-ancestors Which origins may iframe you (clickjacking defense)
base-uri Whether <base> can rewrite the base URL
form-action Where forms may submit
object-src <object> / <embed>. Almost always 'none' now

frame-ancestors 'self' supersedes the old X-Frame-Options. Emit both to also cover older browsers without CSP support.

script-src — three ways to allow inline scripts

The hard part of CSP is "how do you allow inline scripts." A script written directly into HTML as <script>...</script> is blocked by a default CSP (that's the core of the XSS defense) — but legitimate inline scripts get blocked too. Three ways to allow them.

1. 'unsafe-inline' — easy, but it guts CSP

script-src 'self' 'unsafe-inline';

Allows all inline scripts. Trivial to set, but it also allows XSS-injected scripts, so for script-src the CSP becomes nearly powerless. A "just make it run" compromise.

2. Hashes — for static inline scripts

script-src 'self' 'sha256-BASE64HASH';

List the SHA hash of each allowed script's content. Works if the content is fixed; useless for dynamic scripts, and the hash needs updating every time the script changes.

3. Nonce — the right choice for dynamic pages

script-src 'self' 'nonce-RANDOM';

Generate a random string (nonce) per request and put it both in the CSP header and on each <script nonce="RANDOM"> you want to allow. The browser only runs scripts whose nonce matches. An attacker can't know the correct nonce at injection time (it changes per request), so XSS scripts are rejected.

For dynamic Laravel apps, nonce is the default. The nonce implementation follows.

Nonce implementation in Laravel

Generate the nonce in middleware

Make one nonce per request, embed it in the CSP header, and share it so Blade can use it.

class SetSecurityHeaders
{
    public function handle(Request $request, Closure $next): Response
    {
        // 16 random bytes → base64url (~22 chars)
        $nonce = rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '=');

        app()->instance('csp-nonce', $nonce);
        \Illuminate\Support\Facades\Vite::useCspNonce($nonce);
        \Illuminate\Support\Facades\View::share('cspNonce', $nonce);

        $response = $next($request);

        $response->headers->set('Content-Security-Policy',
            "default-src 'self'; "
            ."script-src 'self' 'nonce-{$nonce}' 'strict-dynamic'; "
            ."style-src 'self' 'nonce-{$nonce}'; "
            ."object-src 'none'; base-uri 'self'; frame-ancestors 'self'"
        );

        return $response;
    }
}

Key points:

  • Change the nonce every request. A fixed nonce is predictable and meaningless.
  • Calling Vite::useCspNonce($nonce) makes Laravel's @vite directive auto-attach the nonce to the <script> / <link> tags it generates.
  • View::share('cspNonce', ...) lets Blade templates reference $cspNonce.

What 'strict-dynamic' does

Adding 'strict-dynamic' to script-src allows "child scripts dynamically loaded by a nonce-approved script" without the children carrying the nonce themselves. Vite-generated bundles dynamically import chunks, so without this some chunks get blocked.

Careful: once 'strict-dynamic' is present, any 'self' or host allowlist sitting beside it in the same script-src is ignored by browsers that support it. MDN puts it plainly:

any allowlist or source expressions such as 'self' or 'unsafe-inline' will be ignored — MDN: CSP script-src

The example above keeps 'self' in script-src 'self' 'nonce-{$nonce}' 'strict-dynamic' as a fallback for browsers that don't support 'strict-dynamic' — but in browsers that do, it is doing nothing. Building on the assumption that "'self' is there, so same-origin <script src> will load" breaks only in supporting browsers, which makes it painful to diagnose. Put the nonce on them.

Add the nonce to inline scripts in Blade

If a template has hand-written <script> / <style>, add the nonce attribute.

<script nonce="{{ $cspNonce ?? '' }}">
  /* ... */
</script>

Notes per frontend setup

CSP difficulty depends on the frontend.

Blade / Livewire — easy to go nonce-based

Livewire keeps component initial state in wire:* attributes on HTML elements and loads client JS from external files. It doesn't embed initial state in an inline <script>, so it moves cleanly to a nonce-based CSP without 'unsafe-inline'. Livewire / Flux directives accept a nonce option. Worked example: Livewire CSP nonce article.

Inertia (React / Vue) — also fine with a nonce

Inertia embeds the page's initial props into the first HTML like this:

<script data-page="app" type="application/json">{...}</script>
<div id="app"></div>

That's a type="application/json" data block — the browser never executes it, so script-src doesn't apply. No patch to Inertia is needed to thread a nonce through.

What actually needs the nonce is the executable inline <script> / <style> your starter kit or layout writes (dark-mode detection and similar). Details and implementation in the Inertia CSP nonce article.

Correction: this section previously said Inertia's inline props were the obstacle and that removing 'unsafe-inline' required changing HandleInertiaRequests or moving to SSR. That was reasoning about the behaviour without reading the implementation, and is corrected above.

Roll out safely with report-only

Enforcing immediately breaks the page on any inline script you missed. The Content-Security-Policy-Report-Only header detects violations without blocking.

Content-Security-Policy-Report-Only: default-src 'self'; ...; report-uri /csp-report

Point report-uri / report-to at a violation-report endpoint, observe production traffic for a while, confirm zero violations, then switch to the enforcing header. A safe staged migration.

Testing CSP

The CSP header content can be pinned with a test.

public function test_production_csp_forbids_unsafe_inline(): void
{
    config()->set('app.env', 'production');
    $response = $this->get('/');
    $csp = $response->headers->get('Content-Security-Policy');

    $this->assertStringNotContainsString("'unsafe-inline'", $csp);
    $this->assertMatchesRegularExpression("/script-src [^;]*'nonce-/", $csp);
}

Also worth verifying the nonce changes per request (different value across two requests).

Handling the dev environment

CSP — especially a strict one — collides with Vite's dev server and HMR. Strict-Transport-Security pinned to localhost is also a nuisance. In practice, emit CSP and HSTS in production only, not in local / test.

if (app()->isProduction()) {
    $response->headers->set('Content-Security-Policy', $policy);
    $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}

FAQ

Q. Should I use a nonce or a hash?

For a dynamic Laravel app, a nonce. Hashes require the script's contents to be fixed and have to be regenerated whenever it changes. A nonce is generated per request and placed on both the CSP header and the <script> — nothing to keep in sync.

Q. What happens to 'self' when I add 'strict-dynamic'?

It is ignored. MDN states it plainly: "any allowlist or source expressions such as 'self' or 'unsafe-inline' will be ignored". Building on the assumption that 'self' still admits same-origin <script src> produces a failure that appears only in browsers that support 'strict-dynamic'.

Q. Can I go nonce-based on Livewire or Inertia?

All three stacks can. What differs is which elements need the nonce: Livewire's and Flux's bootstrap scripts on one side, the starter kit's own inline script on the Inertia side. Inertia's payload is a non-executed data block and needs nothing.

Q. Is it safe to enforce straight away?

Any inline script you missed will break the page. Content-Security-Policy-Report-Only reports violations without blocking, so run real traffic through it, clear what it surfaces, and only then switch to enforcing.

Summary

  • CSP is the second arrow against XSS. Use it alongside input handling.
  • script-src is the core. For dynamic Laravel apps, nonce is the default.
  • Generate a per-request nonce in middleware → Vite::useCspNonce + View::share.
  • Livewire is easy to make nonce-based; Inertia's inline props are the obstacle.
  • Stage the rollout with Content-Security-Policy-Report-Only.
  • Emit CSP / HSTS in production only.

Related

License / note

This is a guide, not tied to a specific improvement fork. Code samples are minimal for illustration. Reference environment: Laravel 13.x / PHP 8.5.

Related articles