The security review came back with one line: add a content security policy. The header is genuinely one line. Everything after that is a month of finding out what a decade of markup does that nobody documented, and the only safe way to do it is to ask the browser rather than to read the templates.
The symptom
# what the first attempt looks like
add_header Content-Security-Policy "default-src 'self'" always;
# and the console, immediately
Refused to execute inline script because it violates the following
Content Security Policy directive: "default-src 'self'"
Refused to apply inline style ...
Refused to load the script 'https://www.google-analytics.com/analytics.js'
Refused to connect to 'https://api.intercom.io' ...
# the page renders. nothing on it works.Inline scripts, inline styles, four analytics tags, a chat widget and a font provider — none of which appeared in any inventory, because nobody had ever needed a list of the origins a page talks to.
Why it happens
CSP works by refusing anything not explicitly allowed, and a decade of markup was written when nothing counted script origins. Inline handlers, style attributes set by jQuery, a tag manager that injects scripts at runtime — every one of them is a violation, and the volume is proportional to the site’s age rather than to anything about its quality.
The genuinely hard part is that the set of origins is not static. A tag manager exists so that marketing can add a script without a deploy, which is precisely the thing a policy forbids. That tension is a conversation rather than a configuration, and having it early is better than having it after enforcement breaks a campaign.
The fix
Report-only first, for weeks
add_header Content-Security-Policy-Report-Only
"default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
report-uri /csp-report" always;
# report-only: the browser reports and does NOT block.
# both headers can be sent at once — a strict enforced policy
# and a stricter report-only one, to test the next step.
Sending both headers simultaneously is the mechanism that makes tightening safe: the enforced policy is what the site runs on, and the report-only one is the next iteration collecting evidence. Every step of the ratchet is validated against real traffic before it blocks anything.
// the endpoint. it receives a JSON body with a specific content type.
Route::post('/csp-report', function (Request $request) {
$report = json_decode($request->getContent(), true)['csp-report'] ?? null;
if ($report) {
Log::channel('csp')->info('csp.violation', [
'directive' => $report['violated-directive'] ?? null,
'blocked' => $report['blocked-uri'] ?? null,
'document' => $report['document-uri'] ?? null,
]);
}
return response()->noContent();
})->withoutMiddleware([VerifyCsrfToken::class]);
The CSRF exemption is required — the browser posts this without any of your cookies’ ceremony — and the endpoint must be rate limited, because a single misconfigured page can generate thousands of reports per visitor. Logging to its own channel keeps the volume out of the application log.
Reading the reports, most of which are not your site
$ jq -r '.context.blocked' /var/log/app/csp.json | sort | uniq -c | sort -rn
41208 chrome-extension
18844 inline
2110 https://www.google-analytics.com
1902 moz-extension
884 https://connect.facebook.net
412 safari-extension
18 https://cdn.oldvendor.example ← this one is realRoughly two thirds of the volume is browser extensions injecting scripts into pages, and none of it is actionable — you cannot allow-list an extension and you would not want to. Filtering those out at the endpoint before logging is the first thing to do, or the signal is invisible.
The eighteen reports at the bottom were the finding: a script from a vendor whose contract had ended in 2016, still referenced by one template, still being loaded by every visitor to that page. CSP found it because CSP counts origins, which is a thing nobody else does.
Nonces, and why unsafe-inline defeats the exercise
Allowing unsafe-inline for scripts removes essentially all of the protection, because injected script is inline script. It is a reasonable first rung on the ladder and a bad place to stop.
// middleware: one nonce per response
$nonce = base64_encode(random_bytes(16));
$request->attributes->set('csp_nonce', $nonce);
$response->headers->set('Content-Security-Policy',
"default-src 'self'; " .
"script-src 'self' 'nonce-{$nonce}'; " .
"style-src 'self' 'unsafe-inline'; " .
"object-src 'none'; base-uri 'self'; frame-ancestors 'none'");
// and in the template
// <script nonce="{{ request()->get('csp_nonce') }}">...</script>
The nonce must be different on every response and therefore cannot be on a cached page — which is the constraint that decides whether nonces are usable at all. A site behind full-page caching needs hashes instead, computed at build time from the inline scripts’ exact contents, and those break when the content changes by one character.
object-src 'none' and base-uri 'self' are the two directives worth adding regardless of how far the rest gets. The first removes a whole class of plugin-based attack and breaks nothing in 2018; the second stops injected markup rewriting every relative URL on the page, which is a real and under-appreciated technique.
The tag manager problem
the tag manager exists so marketing can add a script
without a deploy.
CSP exists to stop scripts appearing without review.
these are the same sentence with opposite signs.
the options:
1. allow-list the manager, accept it can load anything
2. allow-list specific vendors, and make each new one a deploy
3. no manager
there is no fourth option. pick one, out loud, with marketing.Option one is what most sites do and it means the policy protects against everything except the vector somebody would actually use. Option two is defensible and turns a five-minute marketing task into a two-day one, which is a real cost to somebody else’s work. Making the trade explicit is the only part that is genuinely engineering’s job.
Verifying it worked
$ curl -sI https://shop.example | grep -i content-security
content-security-policy: default-src 'self'; script-src 'self' 'nonce-...
# violations after enforcement, excluding extensions
$ jq -r 'select(.context.blocked | startswith("http")) | .context.blocked'
/var/log/app/csp.json | sort | uniq -c
0
# and the manual pass that a header check cannot do:
# checkout, with a card
# the chat widget
# every page with an embedded video
# the admin, which nobody testedZero real violations for a week before flipping from report-only to enforced is the gate, and the extension noise has to be excluded or the number is never zero. The manual pass is the part that cannot be automated: a report tells you what was blocked on pages people visited, and the pages nobody visited during the observation window are the ones that break on the Monday after.
What this costs
Every new third-party script is now a deploy, and that is the whole point and the whole objection. It converts adding an analytics tag from a five-minute self-service task into a change with review and a release, which is correct from a security perspective and is a genuine slowdown for people whose job is to add analytics tags. Nobody thanks engineering for this and the value is entirely in incidents that do not happen.
The other cost is that a policy left at unsafe-inline — which is where most sites stop, because getting past it means touching every template — provides a fraction of the protection while looking on a security report exactly like one that does not. That gap between the appearance and the substance is worth being honest about internally, because the alternative is a checkbox ticked and a risk unchanged.