Reading a Content Security Policy: How CSP Stops XSS Before It Runs
A practical guide to Content-Security-Policy (CSP): the directives that matter, why unsafe-inline and wildcards weaken script-src, and how nonces ship safely.
Reading a Content Security Policy: How CSP Stops XSS Before It Runs
The first time I shipped a Content-Security-Policy header to production, I broke our own analytics for about forty minutes. A wildcard I thought was harmless was the only thing keeping a third-party tag alive, and the moment I tightened it the browser quietly refused to load the script. That afternoon taught me more about CSP than any spec did: the header is a whitelist, and the browser enforces it with no mercy and no warning unless you ask for one.
A Content-Security-Policy is a response header that tells the browser exactly where scripts, styles, images, fonts, and frames are allowed to come from. If a resource isn't on the list, it doesn't load. That is the entire value proposition for stopping cross-site scripting (XSS): even if an attacker injects a <script> tag into your page through a comment field or a reflected query parameter, a strict CSP means the browser never executes it, because the injected code isn't on the approved list.
Why CSP matters against XSS
XSS works because the browser trusts every script on the page equally. Your own app.js and an attacker's injected <script>alert(document.cookie)</script> look identical to the parser. Input sanitization tries to stop the injection from ever landing; CSP assumes sanitization will eventually fail and adds a second wall. With script-src 'self', the browser only runs JavaScript served from your own origin. An inline <script> block injected into the HTML has no origin the browser can verify, so it is blocked on sight.
This is the concrete payoff worth memorizing: CSP whitelists where scripts, styles, and images may load, and script-src 'self' blocks injected inline scripts outright. But two common settings, 'unsafe-inline' and the * wildcard, completely undo that protection. Add 'unsafe-inline' and the browser will once again run any inline script, including the attacker's. Add * and you've told the browser that scripts may load from any domain on the internet. Either one turns a strict policy back into a decorative header.
The directives that carry the weight
You don't need every directive to get value, but a handful do most of the work.
default-srcis the fallback. Any resource type you don't explicitly name inherits this value. Settingdefault-src 'self'gives you a safe baseline so a forgotten directive doesn't silently default to "allow everything."script-srccontrols JavaScript. This is the directive that actually stops XSS, so it deserves the strictest value you can tolerate.style-srccontrols CSS, including inlinestyleattributes and<style>blocks. Injected CSS can leak data through attribute selectors, so this matters more than people expect.object-src 'none'kills<object>,<embed>, and legacy plugin vectors. Almost no modern site needs them.base-uri 'self'stops an injected<base>tag from rewriting where every relative URL on your page resolves.frame-ancestors 'self'is the modern clickjacking defense; it replaces the oldX-Frame-Optionsheader.
A quick way to spot the gaps in an existing header is to paste it into the CSP Policy Auditor. It parses each directive, counts your nonce and hash sources, and flags the unsafe-inline, wildcard, and missing-fallback problems that are easy to miss when you're reading a single long line of text.
Why unsafe-inline and wildcards weaken everything
It's worth being precise about how these two settings fail.
'unsafe-inline' re-permits inline event handlers (onclick="...") and inline <script> and <style> blocks. Since injected XSS payloads are almost always inline, allowing inline code reopens the exact hole CSP was meant to close. A policy with script-src 'self' 'unsafe-inline' provides essentially no XSS protection for inline injection.
Wildcards fail more quietly. script-src https: looks restrictive because it requires HTTPS, but it allows scripts from any HTTPS host anywhere. If an attacker can get a payload onto any CDN or any compromised subdomain, your policy waves it through. The same applies to *.example.com when a single subdomain in that set runs user-uploaded content. The strength of a CSP is the narrowness of its source lists, and every wildcard widens the door.
Nonces and hashes: running inline code safely
Sometimes you genuinely need inline JavaScript, like a small bootstrapping snippet or a server-rendered config object. You don't have to choose between 'unsafe-inline' and rewriting your whole app. Nonces and hashes let you allow specific inline code while still blocking everything else.
A nonce is a random value your server generates fresh for every response. You put it in the header (script-src 'nonce-r4nd0m') and on the matching tag (<script nonce="r4nd0m">). The browser only runs inline scripts carrying that exact value. Because an attacker can't predict the per-request nonce, their injected script has no valid nonce and stays blocked.
A hash works without a server round trip. You compute the SHA-256 hash of an inline script's contents and list it (script-src 'sha256-...'). The browser hashes each inline block and runs only the ones that match. Hashes suit static inline snippets that never change; nonces suit dynamic pages.
A worked example for a simple site
Here is a reasonable starting policy for a small site that serves its own JavaScript and CSS, loads images from its own origin plus an avatar CDN, and uses one inline bootstrap script via a nonce:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-rAnd0mPerRequest';
style-src 'self';
img-src 'self' https://avatars.example-cdn.com;
object-src 'none';
base-uri 'self';
frame-ancestors 'self';
upgrade-insecure-requests;
report-uri /csp-report
Notice what this policy does not contain: no 'unsafe-inline', no 'unsafe-eval', no *. The single inline script is permitted only through its nonce. Everything else falls back to default-src 'self', so an image host you forgot to list is denied rather than allowed.
Roll it out in report-only mode first
Do not deploy a strict CSP straight to production. Browsers support a Content-Security-Policy-Report-Only header that enforces nothing but sends a JSON report every time a resource would have been blocked. Ship report-only first, watch the reports for a week or two, and you'll discover every third-party tag, inline handler, and forgotten CDN your real users depend on, without breaking a single page. That forty-minute outage I mentioned would have been a quiet log entry instead.
When the report stream goes quiet, swap -Report-Only for the real header. Keep report-uri (or the newer report-to) in place afterward, since runtime reports keep catching regressions long after launch.
If hardening headers is becoming a habit on your project, it pays to audit the rest of your surface too. An environment secret scanner catches credentials leaking through config files and source, which is the kind of exposure no CSP can protect against. CSP guards the browser; secret hygiene guards everything behind it.
A good Content-Security-Policy is mostly an exercise in saying no precisely. Name your real sources, refuse the wildcards, replace 'unsafe-inline' with nonces, and let report-only mode show you what you missed before your users do.
Made by Toolora · Updated 2026-06-13