Content security policy headers are the control that turns most XSS bugs from "game over" into "blocked in the console." If an attacker injects a <script> tag but the browser refuses to execute it because the source isn't on your allowlist, the payload is inert. The catch: a careless policy either breaks half your site or does nothing useful. The fix is a disciplined rollout. Ship the header in report-only mode first, collect violation reports for a week, fix what would break, then enforce. Below is exactly how I do it on Laravel apps, with the Nginx equivalent for static or proxied stacks.
What does a Content Security Policy actually buy you?
CSP is a response header that tells the browser which sources of script, style, images, and connections are allowed to run on your page. When the browser sees inline JavaScript or a <script src> from a domain you didn't allowlist, it refuses to load it. That single rule neutralizes the most damaging class of stored and reflected XSS. It is defense in depth, not a substitute for output encoding and input validation, but it is the layer that saves you when one of those slips through. Pair it with the rest of your hardening from the Laravel security checklist and you have a real safety net rather than a single point of failure.
Which directives matter, and why unsafe-inline defeats the point
You don't need every directive on day one. These are the ones that carry the weight:
- default-src 'self' — the fallback for any *-src you don't set explicitly. Start here and lock it to your own origin.
- script-src — the most important one. This is what stops injected JavaScript. Never put 'unsafe-inline' here.
- style-src — controls stylesheets and inline styles. Inline CSS is lower risk than script but can still be abused for data exfiltration via attribute selectors.
- img-src — where images may load from; widen to data: only if you actually use data URIs.
- connect-src — governs fetch, XHR, WebSocket, and EventSource destinations. Set this to your API origins so a stolen token can't be beaconed to an attacker host.
- frame-ancestors — who may embed you in an iframe. This replaces the legacy X-Frame-Options header and is your clickjacking control.
The trap is 'unsafe-inline' in script-src. The moment you add it, the browser will execute any inline <script> on the page, including one an attacker injected. That is precisely the attack CSP exists to stop, so 'unsafe-inline' silently turns your policy into decoration. The correct way to allow the handful of inline scripts you genuinely need is a per-request nonce or a SHA-256 hash. A nonce is a random value you generate once per response, put on the header, and stamp on each legitimate <script>. The browser runs only scripts carrying that exact nonce; an injected script can't guess it.
If 'unsafe-inline' is in your script-src, you don't have a Content Security Policy. You have a CSP-shaped placebo.
How do you roll it out without breaking the site?
Never enforce a brand-new policy in production cold. Use the Content-Security-Policy-Report-Only header first. It applies the exact same rules but only reports violations instead of blocking them, so your site keeps working while you watch what would have broken. Point the policy at a reporting endpoint that logs the JSON the browser POSTs. Run it for a week across real traffic, because that is when you discover the analytics snippet, the embedded support widget, and the inline onclick handlers nobody documented. Tighten the policy until the reports go quiet, then swap the header name to Content-Security-Policy to enforce it.
Inline event handlers like onclick="..." are the most common surprise. They are inline script and a nonce can't cover them, so you move them into a nonce'd <script> block. Third-party widgets are the second surprise: each one wants its own domain on script-src and connect-src, and that is a deliberate decision you should make per vendor, not a blanket 'unsafe-inline'.
Setting the header in Laravel with a per-request nonce
A single middleware generates the nonce, shares it with Blade, and writes the header. Registering it globally means every response carries the policy. I emit both the modern report-to directive (backed by the Reporting-Endpoints header) and the older report-uri, because report-to still has uneven support across browsers and in report-only mode; report-uri is the broad-compatibility fallback. This is the report-only variant; flip the header name once the violation reports are clean.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ContentSecurityPolicy
{
public function handle(Request $request, Closure $next): Response
{
// One cryptographically random nonce per request, shared with every Blade view.
// random_bytes() gives raw entropy; base64 makes it a valid nonce token.
$nonce = base64_encode(random_bytes(16));
view()->share('cspNonce', $nonce);
$response = $next($request);
$policy = implode('; ', [
"default-src 'self'",
"script-src 'self' 'nonce-{$nonce}'",
"style-src 'self' 'nonce-{$nonce}'",
"img-src 'self' data:",
"connect-src 'self' https://api.ryn.bd",
"frame-ancestors 'self'",
"base-uri 'self'",
"form-action 'self'",
// report-uri is deprecated but still the most widely honored;
// report-to is the modern replacement, paired with Reporting-Endpoints below.
'report-uri https://ryn.bd/csp-report',
'report-to csp-endpoint',
]);
// Report-only first. Rename to 'Content-Security-Policy' to enforce.
$response->headers->set('Content-Security-Policy-Report-Only', $policy);
$response->headers->set(
'Reporting-Endpoints',
'csp-endpoint="https://ryn.bd/csp-report"'
);
// Companion headers worth setting in the same place.
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload'
);
return $response;
}
}Register it in bootstrap/app.php so it runs on every web response (this is the Laravel 11/12 way; there is no Kernel.php anymore):
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\App\Http\Middleware\ContentSecurityPolicy::class,
]);
})Then stamp the shared nonce onto every inline script in your Blade templates. Anything without it will be reported (or blocked, once enforcing):
<script nonce="{{ $cspNonce }}">
// Legitimate inline script the browser will run.
window.__APP_LOCALE = @json(app()->getLocale());
</script>What if you set headers at the proxy instead?
For static sites, SPAs, or when you'd rather keep security headers out of application code, set them in Nginx. The downside is you can't mint a per-request nonce easily in plain Nginx, so this works best when you have no inline scripts to allow. If you do need nonces, keep the policy in the app. For the broader proxy setup this slots into, see my Nginx reverse proxy configuration walkthrough.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https://api.ryn.bd; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;The always flag matters: without it Nginx skips add_header on error responses like 404 and 500, leaving those pages unprotected. If you set any add_header inside a location block, every inherited header from the server block is dropped in that location and must be repeated, which is a classic footgun.
The companion headers you set alongside CSP
CSP rarely travels alone. Strict-Transport-Security (HSTS) forces HTTPS so a man-in-the-middle can't strip your TLS; only add preload once you are certain every subdomain is HTTPS, because preload is hard to undo. X-Content-Type-Options: nosniff stops the browser from guessing a response is executable script when you served it as something else, which closes a sneaky XSS vector around user-uploaded files. And frame-ancestors inside your CSP fully replaces the old X-Frame-Options header for clickjacking protection, with finer-grained control. If you accept uploads, this pairs directly with the defenses in my post on securing file uploads in PHP, since nosniff and a tight default-src limit what a malicious upload can do even if it lands. For the directive-by-directive reference, the canonical source is the OWASP Secure Headers project.
Treat CSP as a project, not a one-line config. Start report-only, read the violation reports your own endpoint collects, kill every 'unsafe-inline' by moving inline code behind a nonce, then enforce and keep watching the reports. Done this way, the day someone slips an XSS payload past your validation, the browser quietly refuses to run it and you read about the attempt in a log instead of a breach disclosure. That is the whole point, and it costs you one middleware class and a week of patience.

