Content Security Policy in Production: A Practical Guide for Laravel and Vue
Content Security Policy gets described as a header you add and forget. Add it wrong and the site breaks in ways that look like a Vue bug: a blank page, a form that silently does nothing, an analytics script that never fires. Add it right and you get a real defence against injected script — the most common way a compromised dependency turns into stolen user data.
This is the sequence that worked for us: deploy the policy in report-only mode, watch what the browser actually complains about, then promote the rules one at a time.
Start with report-only, and actually read the report
The first version of any CSP should observe, not block:
// bootstrap/app.php
$middleware->headers([
'Content-Security-Policy-Report-Only' => "default-src 'self'; script-src 'self' 'unsafe-inline'; report-uri /csp-report",
]);
The browser then sends every violation to /csp-report as a JSON POST. A few days of real traffic tells you more than an hour of reading documentation: which CDN scripts you actually load, which inline blocks you actually have, which fonts come from where.
The report-uri endpoint is trivial — log it and look at the result:
Route::post('/csp-report', function (Request $request) {
Log::warning('CSP violation', $request->json()->all());
return response('', 204);
});
Do not skip this step. A policy written from documentation always breaks something in production; a policy written from a report usually doesn't.
The two decisions that shape everything else
Inline scripts: use a nonce, never unsafe-inline. The unsafe-inline keyword in script-src defeats the entire purpose of a CSP — it allows the exact thing you're trying to block, which is injected inline script. A nonce is a random value you generate per response and embed in the script tag:
$nonce = base64_encode(random_bytes(16));
$response->headers->set('Content-Security-Policy', implode('; ', [
"default-src 'self'",
"script-src 'self' 'nonce-{$nonce}'",
"style-src 'self' 'nonce-{$nonce}'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"object-src 'none'",
]));
Then in Blade:
<script nonce="{{ $nonce }}">window.app = { csrf: '{{ csrf_token() }}' };</script>
The nonce has to be unpredictable and per-response. Reusing one nonce across requests means an attacker who sees a single page can reuse the nonce forever, which is equivalent to unsafe-inline with extra steps.
Style attributes: decide deliberately. style-src 'unsafe-inline' is usually needed, because Vue's :style bindings and most component libraries set inline styles. If your app can live without inline styles, drop the keyword — but check what happens first. This is exactly the kind of thing the report-only phase is for.
The Vite trap
This is where most Laravel + Vue projects get stuck. Vite injects module preload links and, in dev, a client script that talks to the dev server. Two consequences:
- The dev server needs
connect-src ws://localhost:5173 http://localhost:5173andscript-srcentries for the HMR client. Keep those in a local-only branch of your policy, never in production. - Built assets get hashed filenames, so
script-src 'self'covers them. But the dev server's inline bootstrap script is injected into the HTML — that's what the nonce is for.
In production, verify the built bundle actually loads before you ship the policy. If the page renders but a Vue component is blank, check the browser console for a CSP violation mentioning script-src before you go looking at your code.
Third-party scripts break policies in specific ways
Every embedded script has its own policy needs, and they differ by what they load, not by what they are:
- Ad networks need
script-srcfor their CDN,frame-srcfor the ad frame, andconnect-srcfor the impression beacon. Ad networks also inject inline scripts, which is why they push so hard forunsafe-inline. If you serve ads, plan for a separate, narrowly-scoped allowance. - Analytics usually need only
script-srcandconnect-srcfor the collector endpoint. The most common breakage is missingconnect-src— the script loads fine and then silently fails to send. - Payment widgets (Stripe and similar) need
frame-srcfor their iframe andconnect-srcfor the API. They generally do not needunsafe-inlineif you use their documented integration.
The practical rule: add the third party to the directive it actually needs, and nothing more. A policy that ends up with 'unsafe-inline' in script-src because of one analytics script is a policy with no value.
Object-src and frame-ancestors are free
Two directives cost nothing and close real holes:
object-src 'none'— blocks plugin content (<object>,<embed>). No modern app needs it, and it removes a legacy plugin attack path.frame-ancestors 'none'— blocks your pages from being framed. Use'self'instead if you genuinely embed your own pages elsewhere (a dashboard inside an admin shell, for example).
Enforcement and the staged rollout
Report-only tells you what's wrong. Enforcement tells you what's now wrong, and the two are never the same set.
The rollout that works:
Content-Security-Policy-Report-Onlywith a permissive policy,/csp-reportlogging enabled. Run for a week.- Tighten the policy in report-only mode. Keep going until the report is empty.
- Switch the header to
Content-Security-Policywith the same value. - Add
upgrade-insecure-requestsandStrict-Transport-Security— these are separate headers and belong to the same hardening pass.
A useful last check: after switching to enforcement, grep your own logs for violations again a few days later. Deploys of new features often introduce a new inline script or a new third-party domain, and nobody notices until a customer reports a broken page.
What this actually buys you
A CSP is not a silver bullet, and it will not stop an XSS bug by itself. What it does is turn "attacker runs script on your origin" into "attacker runs script on your origin and the browser refuses". For a SaaS handling billing and customer data, that second version is the difference between one compromised page and a compromised account.
The cost is an afternoon of report-driven work and the discipline to keep the report empty. That's a good trade.