spatie/laravel-csp
Spatie's Laravel CSP package makes it easy to set Content Security Policy headers in Laravel apps with configurable policies and directives. It helps restrict which sites scripts can contact, reducing risks from malicious or compromised JavaScript dependencies.
Install and publish the config:
composer require spatie/laravel-csp
php artisan vendor:publish --tag=csp-config
Register the middleware globally in bootstrap/app.php (Laravel 11+):
use Spatie\Csp\AddCspHeaders;
->withMiddleware(function (Middleware $middleware) {
$middleware->append(AddCspHeaders::class);
})
On Laravel 10 and below, add Spatie\Csp\AddCspHeaders::class to $middleware (or the web group) in app/Http/Kernel.php.
Then verify headers are actually being emitted before touching any configuration:
curl -sI https://your-app.test | grep -i content-security-policy
The default config/csp.php ships with Spatie\Csp\Presets\Basic enabled, so you should immediately see a Content-Security-Policy header. Open DevTools → Network → your document → Response Headers to confirm, and keep the Console tab open — every blocked resource appears there with the exact directive that rejected it.
Where to look first:
config/csp.php — presets, report_only_presets, directives, report_only_directives, enabled.vendor/spatie/laravel-csp/src/Presets/ — read the actual Basic preset and any vendor preset you enable. Each is ~10 lines of $policy->add(...) and tells you exactly which hosts are being whitelisted. Don't enable a preset blind.vendor/spatie/laravel-csp/src/Directive.php and Keyword.php — the enums you'll use in your own preset.The right first use case is not shipping a strict policy. It's this: enable Basic, add report-only presets for whatever third parties you actually use (Stripe, Google Fonts, Sentry, PostHog…), point report_uri at something that collects reports, and let it run for a week. That gives you a real inventory of what your app loads instead of guessing.
Baseline globally, overrides per route group. Keep the app-wide policy in config/csp.php and override it where the surface differs — a marketing page with an embedded Vimeo player, an admin panel, a widget route intended to be iframed:
Route::get('embed/{post}', [EmbedController::class, 'show'])
->middleware(AddCspHeaders::class.':'.App\Http\Csp\EmbeddablePreset::class);
The preset passed as a middleware parameter replaces the configured presets for that route, which is exactly what you want for a frame-ancestors route that must not inherit the app's 'self'-only default.
Write one app preset instead of scattering config directives. Presets are plain classes, easy to review in a PR and easy to test:
namespace App\Csp;
use Spatie\Csp\Directive;
use Spatie\Csp\Keyword;
use Spatie\Csp\Policy;
use Spatie\Csp\Preset;
class AppPreset implements Preset
{
public function configure(Policy $policy): void
{
$policy
->add(Directive::DEFAULT, Keyword::SELF)
->add(Directive::SCRIPT, Keyword::SELF, 'https://cdn.example.com')
->add(Directive::STYLE, Keyword::SELF)
->add(Directive::IMG, Keyword::SELF, 'data:')
->add(Directive::FONT, Keyword::SELF, 'https://fonts.bunny.net')
->add(Directive::CONNECT, Keyword::SELF, 'https://api.example.com')
->addNonce(Directive::SCRIPT)
->addNonce(Directive::STYLE);
}
}
Register it in presets and delete Basic — if you keep both they are merged, and the union is usually looser than either.
Use nonces for inline scripts, not 'unsafe-inline'. With nonces enabled (the default), the package generates one nonce per request and the Blade directive emits the attribute:
<script @cspNonce>
window.__BOOTSTRAP__ = @json($bootstrap);
</script>
csp_nonce() gives you the same value for places Blade directives don't reach (a raw PHP view, an attribute you build manually, an inline event handler you're forced to keep). Any script tag that loads your own JavaScript through a bundler is fine under 'self'; nonces are for the handful of inline boots, GTM snippets, and analytics initialisers.
Staged promotion with report-only. The report_only_presets / report_only_directives arrays produce a separate Content-Security-Policy-Report-Only header that blocks nothing but reports everything. The workflow:
report_only_presets, keep the current one in presets.CSP_REPORT_URI (or the newer CSP_REPORT_TO + reporting_endpoints) to a collector — report-uri.com, a Sentry CSP endpoint, or a tiny controller route.presets and empty report_only_presets.Note report_only_uri / report_only_to fall back to the main values when empty — set them only if you want separate sinks.
Turn it off per environment. CSP_ENABLED=false in .env, plus enabled_while_hot_reloading for local Vite. For a shared staging environment that mirrors production, keep it on so violations surface before release.
Assert the header in tests. CSP is security-relevant behaviour and regressions happen when someone adds a CDN to a layout:
it('sends a csp header', function () {
$this->get('/')->assertHeader('Content-Security-Policy');
});
it('allows the embed route to be framed', function () {
$this->get('/embed/1')
->assertHeader('Content-Security-Policy')
->assertSee('frame-ancestors', false);
});
For anything non-trivial, snapshot the header string — an unexpected directive appearing in the diff is the point.
Meta tag when headers aren't available. @cspMetaTag in your layout <head> emits <meta http-equiv="Content-Security-Policy" ...> for pages served from static HTML, a CDN that strips headers, or an environment where middleware can't run.
enabled_while_hot_reloading defaults to false, so the middleware silently skips when Vite is hot. If you're debugging "the header isn't there", check public/hot before you check your config.nonce_generator, or fall back to 'unsafe-inline' with nonces disabled (CSP_NONCE_ENABLED=false) — explicitly accepting the weaker policy.'unsafe-inline' is ignored when a nonce or hash is present. Adding both doesn't loosen the policy; modern browsers drop 'unsafe-inline'. This bites people adding 'unsafe-inline' to "quickly fix" a breakage while nonces are still on.script-src merge, and you cannot remove a directive another preset set. If a vendor preset is too broad, copy the lines you need into your own preset instead of stacking.'none' with anything else produces a directive browsers may reject or ignore — 'none' must be alone. Watch for this if you add a global directives entry to a policy that already defines that directive in a preset.web group or per-route middleware rather than append on the global stack.frame-ancestors, report-uri, and sandbox are ignored in <meta> CSP. If you need framing protection, it must be a header.report-uri is legacy. Newer browsers prefer the Reporting API via report_to + reporting_endpoints. Configure both during transition or you'll get reports from some browsers and silence from others.new Function in the default build, so a strict script-src without 'unsafe-eval' will break Livewire/Alpine interactivity. Use Alpine's CSP build, or accept Keyword::UNSAFE_EVAL on script-src and document why.onclick="...") are not covered by nonces. They need 'unsafe-hashes' plus a hash, or removal. Treat finding them as a refactor ticket rather than a config change.CSP_ENABLED, hot reload, or middleware registration.nonce_generator (swap RandomString for something edge-cache-friendly), a custom Preset class, and route-level preset injection. Those three cover almost every real-world requirement without forking the package.How can I help you explore Laravel packages today?