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…
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@vitedirective 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-srcis 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
- Hardening the shared Laravel starter-kit backend — introducing security headers including CSP
- Laravel + Livewire Starter Kit: nonce-based CSP — a worked example of dropping
'unsafe-inline'
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
- Hardening the shared Laravel starter-kit backend Laravel's official starter kits (React / Vue / Livewire) differ in their frontend layer, but they share the same Laravel + Fortify backend code. So most of the production-hardening work is identical across all three. This is the pillar art…
- Laravel + Livewire Starter Kit: nonce-based CSP The SetSecurityHeaders middleware shipped in our Livewire Starter Kit Docker-verified fork kept 'unsafe-inline' in script-src and style-src. That was a deliberate placeholder to match the React/Vue forks; the Livewire architecture doesn't …