{} 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-05-18

Verification environment

  • Laravel 13.x
  • PHP 8.5

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

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) — inline props are the obstacle

Inertia embeds the page's initial props into the first HTML as a data-page attribute plus a bootstrap section. That part tends to require 'unsafe-inline'; removing it fully needs changes to HandleInertiaRequests / response rendering, or a move to Inertia SSR. Allowing 'unsafe-inline' first and tightening incrementally is a valid call.

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');
}

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