CSP: Building the Browser's Last Line of Defense with Content Security Policy
How Content Security Policy's allowlist model works, the directives you'll actually use, how nonces and hashes handle inline scripts, and the pitfalls that trip people up in practice
In the XSS post, we mentioned that even careful output encoding can still miss a corner somewhere. Let's dig into that last line of defense: Content Security Policy, or CSP. CSP isn't a replacement for output encoding — it assumes something on the frontend will eventually slip through, and instead fences in what the browser is allowed to execute, so that even when a vulnerability does get hit, the attacker can't easily cash it in.
What problem is CSP actually solving?
Think back to the moment an XSS attack succeeds: the attacker manages to sneak a <script> tag into your page, the browser parses it as ordinary content, and it just runs. Nowhere in that process does the browser stop to question anything — if code shows up in the page's HTML, it executes, whether you wrote it or an attacker snuck it in.
CSP's job is simple: make the browser ask "did this code actually come from somewhere I trust?" Think of it like a building's access control system. Normally, once you're through the front door, you can walk around freely. Add CSP, and now every floor has its own keycard reader — even if someone slips past the lobby (bypassed your output encoding), they still can't get into the server room without the right card.
In practice, the server sends an HTTP header that tells the browser exactly which sources are allowed to supply this page's scripts, styles, images, fonts, and so on. Anything not on the allowlist gets blocked outright — it won't run, it won't load, and the console just logs a violation.
The simplest setup
The most common way to configure CSP is through an HTTP response header:
Content-Security-Policy: default-src 'self'
This says every resource on the page — scripts, styles, images, fonts, Ajax requests, all of it — can only load from 'self', meaning the page's own origin. If you can't touch HTTP headers, you can set the same thing with a <meta> tag, though it's less flexible:
<meta http-equiv="Content-Security-Policy" content="default-src 'self'" />In practice, the HTTP header is the better choice. Some directives — frame-ancestors, for one, which we'll get to — simply don't work in a <meta> tag. Worse, if a malicious script gets injected before the page even applies the CSP, that <meta> tag can be tampered with or bypassed before it ever takes effect.
Splitting rules by resource type
A single default-src usually isn't enough, since the sources you want to allow tend to differ by resource type. CSP gives you a full set of finer-grained directives for exactly this:
Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.example.com;
style-src 'self' https://fonts.googleapis.com;
img-src 'self' https://images.example.com data:;
font-src https://fonts.gstatic.com;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
object-src 'none';
base-uri 'self';
Here are the ones you'll reach for most often:
script-src: controls where JavaScript can load from. Usually the most important line in the whole policy, since scripts are the actual payload of an XSS attack.style-src: controls where CSS stylesheets can come from.img-src: controls image sources. Thedata:here allows base64-embedded images.connect-src: controls which domainsfetch,XMLHttpRequest, and WebSocket connections can reach — this is what actually stops data from being quietly shipped off to an attacker's server.frame-ancestors: controls whether this page can be embedded in someone else's<iframe>. Setting it to'none'also happens to block clickjacking.object-src: controls sources for legacy plugin elements like<object>and<embed>. Turning it off entirely is usually the safest call.base-uri: controls what URL a page's<base>tag can be set to, preventing an attacker from hijacking every relative-path resource on the page by tampering with<base>.
Any directive you don't list falls back to default-src, which is why default-src is usually set to the strictest 'self', with specific exceptions carved out only where you actually need them.
The hard part: inline scripts
This is where most people writing a CSP get stuck. A lot of existing codebases have inline scripts scattered around the page, like this:
<script>
window.APP_CONFIG = { userId: 123 }
</script>Once you add script-src 'self', that inline script gets blocked outright — it's neither an external file nor does it have a URL CSP can check against. The fastest, and most dangerous, fix is 'unsafe-inline':
Content-Security-Policy: script-src 'self' 'unsafe-inline'
This basically tears down the single most important thing CSP does against XSS, because any <script> an attacker manages to inject gets treated as just another inline script and waved through too. It makes the console errors go away, but it also leaves CSP hollowed out — all that's left is restricting where external resources can load from.
The better approach is to allowlist specific inline scripts individually, using a nonce or a hash.
Nonces: marking scripts as trusted
The idea behind a nonce is that every time the server renders the page, it generates a fresh random string, writes it into the CSP header, and stamps the same value onto whichever <script> tags are allowed to run:
Content-Security-Policy: script-src 'self' 'nonce-8IBTHwOdqNKAWeKl7plt8g=='
<script nonce="8IBTHwOdqNKAWeKl7plt8g==">
window.APP_CONFIG = { userId: 123 }
</script>The browser checks whether the tag's nonce attribute matches the value declared in the header, and only runs the script if it does. Since that random string changes on every request, even an attacker who successfully injects a <script> tag has no way of guessing the current request's nonce, so their script never passes the check. One catch: the nonce has to be regenerated on every single request — if the whole site reuses one fixed value, you've essentially written the answer on the exam.
Hashes: locking down scripts with fixed content
If an inline script's content never changes — a hardcoded bit of initialization code, say — you can allowlist it by hash instead:
Content-Security-Policy: script-src 'self' 'sha256-qznLcsROx4GACP2dm0UCKCzCG+HiZ1guq6ZZDob/Tng='
The browser hashes whatever's inside the <script> tag and compares it against the hash listed in the header, only allowing it through on a match. Change even a single character in the script and the hash won't match anymore, so an attacker can't smuggle their own code in through the same mechanism. Hashes work well for scripts with fixed content, while nonces suit content that might change on every request — and you can use both together.
Test with Report-Only before going live
A misconfigured CSP can break your site outright — miss a single third-party font or analytics domain, and users might just see a broken page with no obvious explanation. Before enforcing anything, use the Content-Security-Policy-Report-Only header, which reports violations without actually blocking resources:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report
In this mode, any policy violation just logs a warning to the console and sends the details to whatever endpoint you set in report-uri. That gives you a window to collect real data on which sources you actually need to allow, before switching the policy over to an enforcing Content-Security-Policy header.
Common pitfalls
A few traps worth keeping in mind when writing a CSP:
- Using a wildcard
*forscript-src: this removes any restriction at all — you're left with a header that doesn't actually get checked against anything. - Combining
'unsafe-inline'with a nonce: browsers that support nonces will simply ignore'unsafe-inline', while older browsers that don't support nonces fall back to'unsafe-inline'. This is actually the officially recommended progressive-enhancement pattern — just make sure you're doing it deliberately, not just tacking it on to make an error go away. - Forgetting to allowlist third-party services: things like Google Analytics, Google Fonts, or Stripe scripts and stylesheets tend to break the moment you tighten up your CSP. Run a full inventory with Report-Only mode before going live.
- Only testing CSP in production: if your dev environment doesn't enforce the same policy, you won't catch any of this during development — you'll only find out after deploying, when half your features are suddenly blocked. Apply the same rules in dev too.
Wrapping up
At its core, CSP flips the browser's default behavior from "run whatever code shows up on the page" to "only run what's on the allowlist." It's not a substitute for fixing things at the root with output encoding, but when encoding does miss a spot, CSP is what keeps a successful XSS injection from actually doing anything. In practice, start by locking things down with default-src 'self', use Report-Only mode to collect violation data, and handle inline scripts with nonces or hashes instead of 'unsafe-inline'. Next time you're designing a site's security setup, CSP is worth stacking alongside output encoding and HttpOnly cookies as one more layer of defense.