A strict CSP in Laravel, with Vite
A Content Security Policy (CSP) tells the browser where scripts on the page may come from. If someone manages to inject a foreign script, for example through an input field, the browser does not run it. The hard part is that my own inline scripts have to be allowed too, without turning them into a loophole.
Nonce: one identifier per request
The middleware generates a random identifier on every request. I put it in the header and on every one of my inline scripts. The browser only runs a script whose identifier matches. Vite can apply it to its own tags as well.
$nonce = base64_encode(random_bytes(16));
Vite::useCspNonce($nonce); // Vite puts the nonce on its own tags
View::share('cspNonce', $nonce); // used as <script nonce="..."> on every inline script
$response = $next($request);
$response->headers->set('Content-Security-Policy', implode('; ', [
"default-src 'self'",
"script-src 'self' 'nonce-{$nonce}'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'self'",
]));
An excerpt of real code, anonymised.
What to watch out for
- Because of the Vite dev server I turn the CSP off while it runs, otherwise development does not work.
- For styles I kept inline allowed, because style attributes cannot be protected with an identifier.
- I only allow an external service (analytics, captcha) when it is configured.
- A test checks that every inline script has an identifier, so a new script cannot slip through unprotected.
I check the result in the browser console: if something is blocked, it shows up there immediately.
Working on a similar problem?
If you are stuck, or want a second look at your solution, write to me. I am happy to look.
Write to me →More articles
Bot protection for a contact form without a captcha
Five invisible layers that stop most unwanted messages, while a real visitor has to do nothing at all.
Read →A Laravel gotcha: the throttle counter is shared
throttle:5,1 and throttle:120,1 use the same counter per IP. That is how my analytics beacon used up the form's allowance.
Read →