Content Security Policy
Date: 2026-08-16
A header listing what the page is allowed to load and execute. It’s the main defence that turns a cross-site scripting bug from an account takeover into nothing — and it’s also the thing that breaks every tag manager on first deployment.
What it is
Content Security Policy (CSP) is an HTTP response header declaring which sources of scripts, styles, images, frames and connections the browser may honour. Anything not on the list is blocked.
Where The Same-Origin Policy governs what your page may read from elsewhere, CSP governs what it may load and run at all.
Content-Security-Policy:
default-src 'self';
script-src 'self' https://www.googletagmanager.com;
style-src 'self' 'unsafe-inline';
img-src 'self' https: data:;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
The directives that matter
| Directive | Controls |
|---|---|
default-src | Fallback for anything not otherwise specified |
script-src | JavaScript. The one that provides the security |
style-src | Stylesheets and inline styles |
img-src | Images |
connect-src | fetch, XHR, WebSocket, sendBeacon |
frame-src | What may be embedded in your page |
frame-ancestors | Who may embed you — the modern replacement for X-Frame-Options |
form-action | Where forms may submit. Stops an injected form exfiltrating data |
base-uri | Prevents an injected <base> redirecting every relative URL |
frame-ancestors 'none' and base-uri 'self' are cheap, high-value, and routinely forgotten.
Why it works
An XSS vulnerability lets an attacker inject a script. CSP means the injected script has nowhere to run from — it isn’t on an allowed origin, and inline execution is blocked.
without CSP injected <script> runs → reads cookies, storage, form data
with CSP injected <script> blocked → the bug is still there, and inert
That’s the whole value proposition: defence in depth. It doesn’t fix the vulnerability; it removes the payoff.
unsafe-inline undoes it
The catch. Blocking inline scripts is what stops injected ones, so:
script-src 'self' 'unsafe-inline' ← provides essentially no XSS protection
Any policy with 'unsafe-inline' in script-src is decorative. Since most sites have inline scripts — a tag manager snippet, a JSON blob, an analytics initialiser — this is where CSP projects usually stall.
The two ways out:
Nonces. A random value generated per response, put on both the header and each permitted inline script. An injected script can’t guess it.
Content-Security-Policy: script-src 'nonce-r4nd0m123'
<script nonce="r4nd0m123">dataLayer.push({...});</script>The nonce must be unique per response and unguessable. A static nonce is worse than none, because it looks like protection.
Hashes. The SHA hash of the exact script contents. No server-side generation needed, but the hash changes whenever the script does, which makes it unworkable for anything dynamic.
The tag manager problem
This is where CSP meets commercial reality, and it’s worth being clear-eyed about.
A tag manager’s entire purpose is letting people add arbitrary third-party scripts without a deploy. CSP’s entire purpose is preventing arbitrary scripts from running. They are in direct opposition.
The options, none free:
- Allowlist every vendor domain. Workable, but the list grows with every tag, and each entry is a trusted origin that could serve anything.
'strict-dynamic'— where a trusted script may load further scripts — is the usual compromise and it substantially widens the hole - Move tags server-side. One first-party endpoint, vendor calls made from your server. Fixes CSP, performance and ad blocking at once, and costs real engineering — Server-Side Tag Management
- Accept a weaker policy on marketing pages and a strict one on checkout and account pages, where the data is. A defensible, common split
Deploying it
Never enforce first. Use report-only:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report
This blocks nothing and reports what would have been blocked. Run it for a fortnight, read the reports, then enforce.
Expect the reports to be noisy — browser extensions inject scripts into everyone’s pages and will generate violations you can’t act on. Filtering those out is most of the work of reading a CSP report stream.
Practical
- Start report-only, always
frame-ancestors 'none'unless you’re deliberately embeddable — clickjacking protection for one linebase-uri 'self'— cheap, and closes a real injection route- Include
connect-srcor you’ll block your own analytics beacons and see it as a tracking bug - Audit the allowlist periodically. Every entry is a third party that can execute in your users’ sessions, and they accumulate — Third-Party Scripts, Supply Chain Risk