Implementing a Content Security Policy on a Laravel Site
A Content Security Policy tells the browser which sources a page may load code, styles, images and fonts from, and where it may send data. It's the control that limits the damage when something does get injected, and it's the one security header that can break your application if you enforce it without looking first.
This is the sequence we used on this site, in the order we'd do it again.
Step 1: decide where the header comes from
Two options, and the choice is about one thing only.
nginx if the policy is identical on every response. It applies to static files and error pages as well, PHP never runs, and there's nothing to remember in the application. This is where ours lives.
Laravel middleware if you need a value that changes per request — in practice, nonces. A nonce is a random token generated per response, put on the header and on every inline <script>, so the browser runs the inline blocks you marked and refuses any that an attacker injected. Only the application can do that, because the views rendering the inline blocks need the same value the header carries.
Start with nginx unless you're going straight to nonces. Moving it into PHP later is a contained change, and you'll have a working policy in the meantime.
Step 2: write the starting policy
Ours, directive by directive:
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; frame-src 'self' https://cal.com; object-src 'none'; report-uri /csp-report" always;
default-src 'self'is the foundation. Everything not named below falls back to it, so anything you forget defaults to same-origin.script-srcis the directive that matters most.'unsafe-inline'and'unsafe-eval'both weaken it, and most applications need at least the first to start. Getting rid of them is step 7, not step 2.img-src 'self' data:—data:covers inline SVGs and generated images. It's a common requirement and a mild one.connect-src 'self'stopsfetchandXMLHttpRequestreaching another origin. Along withimg-src, this is what blocks stolen data from leaving: the payload has to get home somehow, and every route home is a request to somebody else's server.base-uri 'self'stops an injected<base>tag silently repointing every relative URL on the page. Cheap, and easy to leave out.form-action 'self'stops a form being repointed at another origin.frame-ancestors 'none'is the modern clickjacking control, and the reasonX-Frame-Optionsis now a fallback for older browsers.object-src 'none'kills plugin embeds. Nothing legitimate has needed this in years.report-uriis where violations go, and it's what makes the whole rollout possible.
always matters on the nginx side: without it the header is skipped on error responses, and a 404 or a 500 is exactly when you don't want framing.
One nginx trap that will cost you an afternoon: add_header is inherited from an outer level only when the inner level declares no add_header of its own. A single add_header inside your server block, or any location block in it, silently drops every header set above. No error, no warning — they just stop arriving.
Step 3: build the report endpoint before you send anything
A route, throttled:
Route::post('/csp-report', [CspReportController::class, 'store'])
->middleware('throttle:csp-report');
It has to be exempt from CSRF, because the browser posts it unauthenticated with no session and no token:
$middleware->validateCsrfTokens(except: ['csp-report']);
Which makes it a public, unauthenticated write endpoint, so treat it as one. Ours allows 30 a minute per IP, caps every string, and writes to its own log channel so reports don't drown the application log:
'csp' => [
'driver' => 'daily',
'path' => storage_path('logs/csp.log'),
'permission' => 0640,
'level' => 'info',
'days' => env('LOG_CSP_DAYS', 14),
],
Two payload shapes, and you need both. The original spec posts {"csp-report": {...}} as application/csp-report. The newer Reporting API posts an array of {"type": "csp-violation", "body": {...}} as application/reports+json, with camelCase keys inside. Handle one and you silently collect half your reports:
if (isset($payload['csp-report']) && is_array($payload['csp-report'])) {
return [$payload['csp-report']];
}
if (array_is_list($payload)) {
return collect($payload)
->filter(fn ($report) => is_array($report) && is_array($report['body'] ?? null))
->map(fn ($report) => $report['body'])
->take(20)
->all();
}
Because the keys differ between the two, read both spellings when you log:
'directive' => $this->str($violation['effective-directive'] ?? $violation['effectiveDirective'] ?? ''),
'blocked' => $this->str($violation['blocked-uri'] ?? $violation['blockedURL'] ?? ''),
Then filter extension noise, or the log is unusable. Password managers, ad blockers and translation extensions inject scripts into your pages constantly, and every one generates violations that say nothing about your policy:
private const IGNORED_PREFIXES = [
'chrome-extension', 'moz-extension', 'safari-extension',
'safari-web-extension', 'webkit-masked-url',
];
Answer with 204. The browser wants nothing back.
Step 4: send it Report-Only, alongside a safe enforced policy
Content-Security-Policy-Report-Only has identical syntax, blocks nothing, and reports everything it would have blocked. Both headers can be sent at once and browsers apply them independently.
So enforce something obviously safe from day one, and send the strict policy in Report-Only next to it, both pointing at the same report-uri. You get protection immediately and evidence about the policy you actually want.
Step 5: use the whole application, then read the log
Reports only arrive for pages somebody loads, so waiting passively tests only your homepage. Walk it deliberately: the public pages, every form, the admin, the parts of the admin you rarely open.
Each violation falls into one of three buckets, and the middle one is the interesting one:
- Something you can fix at the source. Best outcome — the policy stays tight.
- Something you didn't know your application did. This is the value, and it is the reason to do the Report-Only stage properly. Ours turned up a framework default quietly sending user data to a third party on every admin page load — invisible in the source, obvious in the reports. What two days of reports caught is the longer version.
- Something a dependency requires and you can't change. Grant it, and write down why.
Step 6: promote it, and keep reporting
Rename the header, reload, done. Then leave report-uri in place — enforced policies still report, with "disposition": "enforce", so a policy you break in six months announces itself in a log rather than as a white screen a visitor never mentions.
Write the revert down where the policy lives, because you'll want it under pressure and not from memory. Ours says: rename the header back to Content-Security-Policy-Report-Only and systemctl reload nginx.
Step 7: what's left, and what it's worth
Our policy carries 'unsafe-inline' and 'unsafe-eval'. Both are real weaknesses and calling the header "done" would be overselling it.
The upgrade that matters is dropping 'unsafe-inline' for scripts, which means nonces, which means generating the header in PHP — step 1's other branch. 'unsafe-eval' would still have to stay if you run Livewire, because Alpine evaluates its attribute expressions at runtime. We haven't done this yet on this site; it's a bigger change than everything above put together.
Even with both, the policy is worth having. An injected script can't load code from another origin and can't send anything to one, which is most of what an injected script is for.
Verifying it works
Check the header arrives, on a page and on a 404:
curl -sI https://example.com/ | grep -i content-security
curl -sI https://example.com/does-not-exist | grep -i content-security
Then prove the reporting path end to end, because a policy that reports nowhere fails silently:
curl -sS -X POST https://example.com/csp-report \
-H 'Content-Type: application/csp-report' \
-d '{"csp-report":{"effective-directive":"img-src","blocked-uri":"https://example.net/x.png"}}' \
-w '%{http_code}\n'
That should return 204 and put one line in storage/logs/csp-*.log. If the log stays empty, fix that before trusting anything the browser doesn't tell you.
Last, open the site with the console visible and click through it. Blocked resources are reported there in plain language, and it's faster than reading the log while you're still shaping the policy.
We do this kind of work on Laravel applications and inherited web applications. If you'd like someone to put a policy on yours and watch it before enforcing, tell us what you're running.
Frequently asked questions
Should the CSP header be set in nginx or in Laravel middleware?
nginx if the policy is the same on every response, because it then applies to static files and error pages too, with no PHP involved. Laravel middleware if you want per-request nonces, since the value has to change on every response and be available to the views rendering the inline blocks. Both are legitimate; start with the simpler one.
How long should I leave a policy in Report-Only?
Long enough to have used everything, which is usually a few days rather than a few weeks. The reports only tell you about pages somebody visited, so an admin area nobody opened during the trial is untested. Walk the whole application deliberately instead of waiting passively.
What happens to reporting once the policy is enforced?
It keeps working. Enforced policies still send reports, with a disposition of enforce, so anything you break later announces itself in a log instead of as a blank page a visitor never tells you about. Keep the report-uri after you promote.