A Tight Content Security Policy for Laravel in One Middleware
A strict Content Security Policy sounds like a project. In Laravel it is one middleware and a handful of directives. Here is the whole thing, why each line is there, and how to keep it from breaking local dev.
Introduction
Content Security Policy is not hard and is important to take every precaution to lock down your site from attackers. You read the spec, you see a dozen directives and strategies, and you might put it off because there is more pressing work in front of you right now. I impress upon you that this is not difficult and will give you peace of mind while you work on the important customer facing features and updates.
I run a Laravel app where I wanted the header locked down from the start, not bolted on later. I have mainly done my CSP headers in nginx before, but since I deploy this project to Laravel Forge, They have an nginx config setup that is great, but I want it to be deliberately version controlled. This approach requires no package, no config file, no build step, just a single middleware file that sets a default-src 'none' policy and allowlists for the handful of origins the app actually uses. My app currently scores an A+ on Mozilla Observatory.
The point here is not that CSP is clever, however that a strict CSP, the kind that actually stops injected scripts, is within reach, and once you see where each directive comes from it stops feeling like a spec-reading exercise.
Requirements
When designing this, I had some requirements for myself:
- Deny by default - the policy should start from “nothing is allowed” and only open up for origins I can name. An allowlist I have to think about beats a denylist I have to remember to update.
- No
unsafe-inline, nounsafe-eval- the whole value of CSP evaporates the moment you allow arbitrary inline script. Inline scripts I control should run via a nonce; everything else stays forbidden. - One source of truth - the policy lives in exactly one place in the app, not scattered across nginx
add_headerlines,.htaccess, and a meta tag that all drift apart. - Don’t break local dev - Vite’s hot-reload server serves assets from its own origin over a WebSocket. A same-origin policy would kill it. The dev experience has to survive the strict production policy.
- Play nicely with the edge - the app sits behind Cloudflare, which injects its own scripts. The policy has to accommodate that without punching a hole big enough to drive anything else through.
The whole middleware
This is the real middleware that I use for my application:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Foundation\Vite;
use Illuminate\Http\Request;
class SecurityHeaders
{
public function handle(Request $request, Closure $next)
{
// Generate the nonce before the view renders so Laravel's @vite tags
// (and cooperating packages) can stamp it on their scripts.
$nonce = app(Vite::class)->useCspNonce();
$response = $next($request);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
// The shared allowlist. Add a new origin (a CDN, a font host) here
// once and it applies to every environment.
$policy = [
'default-src' => ["'none'"],
'script-src' => ["'self'", "'nonce-{$nonce}'", 'https://static.cloudflareinsights.com'],
'style-src' => ["'self'"],
'img-src' => ["'self'"],
'font-src' => ["'self'"],
'connect-src' => ["'self'", 'https://cloudflareinsights.com'],
'object-src' => ["'none'"],
'base-uri' => ["'self'"],
'form-action' => ["'self'"],
'frame-ancestors' => ["'none'"],
'worker-src' => ["'none'"],
];
// When the Vite dev server is running we append its origin and HMR
// socket rather than switching the policy off, so an unexpected
// third-party origin still trips the CSP while we develop locally.
if (app()->environment('local') && app(Vite::class)->isRunningHot()) {
$origin = trim((string) file_get_contents(public_path('hot')));
$socket = str_replace(['http://', 'https://'], ['ws://', 'wss://'], $origin);
$policy['script-src'][] = $origin;
$policy['style-src'][] = $origin; // Vite serves the stylesheet from its origin.
$policy['style-src'][] = "'unsafe-inline'"; // ...and injects inline styles on hot updates.
$policy['img-src'][] = $origin;
$policy['font-src'][] = $origin;
$policy['connect-src'][] = $origin;
$policy['connect-src'][] = $socket;
}
$header = collect($policy)
->map(fn (array $sources, string $directive) => $directive.' '.implode(' ', $sources))
->implode('; ');
$response->headers->set('Content-Security-Policy', $header);
return $response;
}
}
Register it and you are done. In a Laravel app, you add a ->withMiddleWare() call to bootstrap/app.php:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
\App\Http\Middleware\SecurityHeaders::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();
Deny by default, then allowlist
By locking down the default-src to start with, this allows you to create the CSP in a way that only allows explicity what you want to allow.
default-src is the fallback for every fetch directive I do not name explicitly. Setting it to 'none' means that unless I say otherwise, the browser refuses to load anything: no scripts, no styles, no images, no fonts, no frames, no connections. From there I am building an allowlist, opening one directive at a time for things the app genuinely needs.
That satisfies requirement one and it is the reason the rest of the policy reads the way it does. Each line that follows is me confirming the app needs that one specific thing. img-src 'self' because I serve my own images. font-src 'self' because the fonts are self-hosted. style-src 'self' because the stylesheet is a real file, not inline. If a directive is not in the list, it inherits 'none' and stays shut. I never have to keep a mental list of things to forbid, which is the part of CSP that usually rots.
A few of the directives are 'none' on purpose even though default-src would already cover them:
object-src 'none'
frame-ancestors 'none'
worker-src 'none'
object-src 'none' kills <object>, <embed>, and applet-style legacy plugins, which are a classic injection vector. frame-ancestors 'none' is the modern replacement for X-Frame-Options: DENY: nobody can put my pages in an iframe, so clickjacking is off the table. worker-src 'none' says the app spins up no web or service workers.
Writing them explicitly is good practice and provides documentation: anyone reading the policy can see these were decisions, and knows that they are there in case they do need legitimate values. If they try to add something to the application, this will alert them that they need to create an entry in the CSP and will hopefully give them a moment to think “Do I really need this external service? How would it increase an attack surface?”
The nonce, and why inline scripts stay forbidden
The single most valuable thing a policy does is refuse inline script, because that is exactly what an XSS payload can exist. The moment you add 'unsafe-inline' to script-src, you have told the browser “run whatever script text shows up in the HTML,” and an attacker who can inject a <script> tag is back in business.
So script-src never gets 'unsafe-inline'. Instead it gets a nonce:
$nonce = app(Vite::class)->useCspNonce();
// ...
"script-src 'self' 'nonce-{$nonce}' https://static.cloudflareinsights.com",
A nonce is a fresh random token generated per request. An inline <script> runs only if it carries a matching nonce="..." attribute, and because the value is unpredictable and regenerated every response, injected markup can never guess it. Self-hosted script files are allowed by 'self'; the Cloudflare origin is there for their analytics script. Everything else, including any inline script an attacker manages to inject, is denied.
The detail worth calling out is that I do not generate this nonce by hand. It comes from Laravel’s own Vite integration via useCspNonce(). I call it before the response renders, so Laravel stamps the same nonce onto the @vite script tags automatically, cooperating packages read it back through Vite::cspNonce() (Laravel Boost’s dev-only browser logger does exactly this), and Cloudflare’s JavaScript Detections parses it out of the header to stamp the scripts it injects at the edge. One value, shared by everything that legitimately needs to run a script. What it pointedly does not cover is an inline <script> an attacker injects: that markup never carries the nonce, so the browser refuses it. I have no hand-written inline scripts of my own, but if you do, render the same value into them, for example nonce="{{ Vite::cspNonce() }}" in a Blade layout.
There is no 'unsafe-inline' and no 'unsafe-eval' anywhere in the policy, so the browser will not run inline script text and will not eval() strings into code.
One source of truth
Requirement three is less about a directive and more about discipline. It is tempting to set security headers wherever is convenient: an add_header in the nginx server block, a line in .htaccess, a <meta http-equiv> in the layout. The problem is that a CSP spread across three files is a CSP nobody can reason about, and the day you tighten one copy and forget another is the day you either break the site or quietly reopen the hole.
Putting the whole policy in one middleware means there is exactly one place to read, one place to change, and one place to test. The header the browser sees comes from PHP, full stop. That is worth more than any individual directive, because a policy you can hold in your head is a policy you will actually maintain.
Don’t break local dev
During development, Vite runs a hot-reload server that serves your JS and CSS from its own origin (a different port) and pushes updates over a WebSocket. A strict default-src 'none' / connect-src 'self' policy blocks exactly that: the browser refuses to load assets from the dev server or open its socket, and hot reload dies.
The lazy fix is to switch the CSP off entirely in local dev. I did that at first, and it has a subtle cost: while the policy is off, a new <script src="https://some-new-service.com/..."> loads without complaint, so I would not find out I had opened a hole until the page hit staging or production with the policy back on. I wanted the CSP working while I develop, so a new origin trips it right when I add it.
So instead of skipping, I keep the policy on and widen it just enough to let the dev server through. When Vite is running hot I read its origin out of the hot file and allow that one origin for scripts, images, and fonts, plus its WebSocket for HMR:
if (app()->environment('local') && app(Vite::class)->isRunningHot()) {
$origin = trim((string) file_get_contents(public_path('hot')));
$socket = str_replace(['http://', 'https://'], ['ws://', 'wss://'], $origin);
$policy['script-src'][] = $origin;
$policy['style-src'][] = $origin;
$policy['connect-src'][] = $origin;
$policy['connect-src'][] = $socket;
// (img-src and font-src get $origin too; style-src also gets 'unsafe-inline'.)
}
The payoff is that requirement four is met without giving anything back on requirement one. Hot reload works, and the policy is still enforcing the whole time: add an unexpected third-party origin during local dev and the browser blocks it immediately, which is exactly the moment you want to stop and ask whether you really need it.
Playing nicely with the edge
The last requirement is the reason two specific origins appear in an otherwise self-only policy:
script-src 'self' 'nonce-{$nonce}' https://static.cloudflareinsights.com
connect-src 'self' https://cloudflareinsights.com
The app sits behind Cloudflare, and Cloudflare’s analytics loads a script from static.cloudflareinsights.com and beacons back to cloudflareinsights.com. Those are the only two third-party origins in the whole policy, and they are named explicitly rather than covered by a wildcard. If I dropped Cloudflare tomorrow, I would delete two strings and the policy would be pure 'self'.
This is the shape I would push anyone toward: your baseline is 'none' if you are truly not using that type of content, then 'self', and every external origin is a line you added deliberately, for a service you can point at, that you could remove if your app no longer depends on that service. When the allowlist is short enough to audit at a glance, you can actually tell whether it is still correct.
Conclusion
Back to the requirements, and how the middleware meets each one:
- Deny by default -
default-src 'none'starts from nothing and every other directive is an explicit allowlist entry. - No
unsafe-inline, nounsafe-eval- inline script runs only via a per-request nonce; the unsafe keywords appear nowhere, so injected script cannot execute. - One source of truth - the entire policy lives in one middleware, not in multiple places creating opportunity for conflicts or drift.
- Don’t break local dev - the policy is skipped only when the app is
localand Vite is hot. - Play nicely with the edge - the only third-party origins are Cloudflare’s two, named explicitly and trivial to remove.
The takeaway I would leave you with is that “strict CSP” and “a lot of work” are not the same thing. The strictness comes from default-src 'none' and the absence of 'unsafe-inline', and both of those are one word each. The rest is a short list of the origins your app actually talks to. Start from nothing, allow what you need.
And you do not have to guess whether it worked. Paste your URL into Mozilla Observatory and it grades your headers, tells you exactly what is missing or too loose, and gives you a concrete list to work down. It is a fast feedback loop: tighten a directive, redeploy, rescan, watch the score move. Getting to that A+ was mostly a matter of doing what it told me, one recommendation at a time.
✌️ Matt