⏱️ Lectura: 14 min
An attacker injects a <script> into a comment field and the browser executes it exactly as if the site itself had written it: that’s how a classic XSS attack works. Content Security Policy is the HTTP header that tells the browser, through an explicit list, which origins it can load scripts, styles, and images from, and blocks everything else before it runs.
📑 En este artículo
- TL;DR
- What Content Security Policy Is and Why It Matters
- How Content Security Policy Works Internally
- Practical Examples: From a Minimal Policy to a Real One
- How to Get Started: Concrete Steps to Enable CSP
- Real-World Use Cases
- Common Mistakes and Best Practices
- Comparison: CSP vs. Other XSS Defenses
- Going Deeper: Nonces, Hashes, and strict-dynamic
- Frequently Asked Questions
- What’s the difference between Content-Security-Policy and Content-Security-Policy-Report-Only?
- Does CSP replace HTML sanitization?
- Does Content Security Policy work in all browsers?
- Can I use CSP with just a meta tag, without touching the server?
- What is ‘strict-dynamic’ and when should I use it?
- Does CSP protect against CSRF attacks?
- References
It doesn’t replace sanitizing HTML on the server side, but it works as a second barrier: if something slips through anyway, CSP can prevent that code from actually running in the victim’s browser.
TL;DR
- You’ll understand how the Content-Security-Policy header blocks unauthorized scripts before they execute.
- You’ll be able to write a functional CSP policy with default-src, script-src, and style-src in minutes.
- You’ll distinguish between the mode that blocks (Content-Security-Policy) and the one that only reports (Content-Security-Policy-Report-Only).
- You’ll use nonces and hashes to allow inline scripts without opening the door to XSS.
- You’ll know how to diagnose a broken policy by reading the errors in the browser console.
- You’ll learn which directives replace obsolete headers like X-XSS-Protection.
- You’ll identify the most common mistakes that break an entire site the first time CSP is enabled.
What Content Security Policy Is and Why It Matters
Content Security Policy (CSP) is a W3C standard implemented as an HTTP response header. The server sends it with every page, and the browser uses it as an allowlist: only scripts, stylesheets, images, fonts, or connections coming from explicitly permitted origins get loaded.
The core motivation is cross-site scripting (XSS): an attack where malicious code gets injected into a trusted page, whether through a poorly sanitized form field, a reflected URL parameter, or a compromised npm dependency. Without CSP, any script that ends up in the HTML runs with the same privileges as the site’s legitimate code: it can read cookies, capture passwords from forms, or redirect the user.
With an active policy, that same injected script gets blocked if its origin isn’t on the allowed list. The browser doesn’t download it, doesn’t execute it, and if you’ve set up a reporting endpoint, it notifies you that it happened.
It’s important to understand what Content Security Policy doesn’t do: it doesn’t sanitize HTML, doesn’t validate inputs, and doesn’t replace data escaping on the server or framework side. It’s an additional layer that limits the damage when the first line of defense fails.
How Content Security Policy Works Internally
A CSP policy is made up of directives separated by semicolons. Each directive controls a type of resource: script-src for JavaScript, style-src for CSS, img-src for images, connect-src for fetch and WebSocket, font-src for fonts. If a specific directive isn’t defined, the browser falls back to default-src.
Each value within a directive is an allowed source: it can be a keyword like 'self' (the same origin), an explicit domain like https://cdn.example.com, or a scheme like data:. There are also more granular keywords, such as 'nonce-xxxx' or 'sha256-xxxx', which allow a specific script block without opening up the entire origin.
The browser evaluates this list on every resource load attempt, not just once when the page starts. If a script gets inserted dynamically after the initial load, for example via innerHTML, it goes through the same check too.
It’s worth clarifying the difference between report-uri and report-to: the first is the original mechanism, now marked as deprecated but still widely supported; the second is part of the modern Reporting API and lets you group several types of reports (CSP, permissions headers, network errors) into a single endpoint. Many teams configure both in parallel during the transition.
Another detail that surprises people the first time: script-src also controls whether code can use eval() or the Function() constructor. By default, without the 'unsafe-eval' keyword, any call to eval throws an exception in the console, even if the script itself comes from an allowed origin.
flowchart TD
A["Browser requests the page"] --> B["Server responds with CSP header"]
B --> C["Browser parses the policy"]
C --> D{"Resource allowed by script-src?"}
D -->|"Yes"| E["The script runs"]
D -->|"No"| F["The script is blocked and reported"]
When a resource gets blocked, the browser fires the SecurityPolicyViolationEvent in JavaScript and, if you’ve configured a reporting endpoint, sends a JSON with the details to your server. That report includes the document where it happened, the violated directive, and the blocked source.
sequenceDiagram
participant A as Attacker
participant N as Browser
participant S as Server
A->>N: injects a script into a comment field
N->>S: requests the page that includes the comment
S-->>N: responds with HTML plus Content-Security-Policy header
N->>N: compares the script's origin against script-src
Note over N: the injected origin is not on the allowed list
N-->>A: the script does not run, the violation gets logged
Practical Examples: From a Minimal Policy to a Real One
The simplest possible example is restricting everything to the same origin. This is the typical starting point before fine-tuning directive by directive:
Content-Security-Policy: default-src 'self'
This single line tells the browser that scripts, styles, images, and everything else can only come from the site’s own domain. Any script from an external CDN, any third-party iframe, and any inline <script> get blocked immediately.
In practice, almost no real site can get by with a single directive: there’s third-party analytics, Google Fonts, base64 images, and calls to an API on another subdomain. A more realistic policy, generated dynamically in an Express middleware, looks like this:
const crypto = require('crypto');
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; " +
`script-src 'self' 'nonce-${nonce}'; ` +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"connect-src 'self' https://api.example.com; " +
"font-src 'self' https://fonts.gstatic.com"
);
next();
});
This middleware generates a different random nonce on every request and injects it both into the header and into each <script nonce="..."> tag in the HTML. An attacker who manages to inject a script can’t guess that value, so their code stays off the allowed list even if the rest of the policy is permissive in other respects.
Each directive in this block plays a different role: script-src only allows JavaScript from the site’s own origin plus the block marked with the current request’s nonce; style-src lets inline CSS through because many UI frameworks inject styles dynamically and migrating that to nonces takes more work; img-src allows own images, base64 images, and images from any HTTPS origin, useful when users upload content; connect-src limits which domains fetch or XMLHttpRequest can call, key to preventing data exfiltration if a script manages to run anyway.
How to Get Started: Concrete Steps to Enable CSP
Enabling a policy in blocking mode directly in production, without testing it first, usually breaks the site. The safe path has four steps.
Step 1: Audit what your site currently loads. Open the developer tools, Network tab, and filter by JS, CSS, Img, and Fetch/XHR. Note down all the external domains that show up: CDNs, analytics, fonts, third-party widgets.
Step 2: Deploy in Report-Only mode. This mode doesn’t block anything, it just logs what would have been blocked. It’s the way to measure the real impact before breaking something:
Content-Security-Policy-Report-Only: default-src 'self'; report-to csp-endpoint
For reports to actually go somewhere, you need to declare the endpoint with the Reporting API:
Reporting-Endpoints: csp-endpoint="https://your-site.com/csp-reports"
Step 3: Review the reports for a few days. Each violation arrives as JSON at your endpoint with the field blocked-uri (what got blocked) and violated-directive (which rule blocked it). With that, you adjust the policy, adding the legitimate origins that are missing.
Step 4: Switch to blocking mode. Once reports drop to zero for several days straight, change the header from Content-Security-Policy-Report-Only to Content-Security-Policy. From that point on, anything outside the list gets actually blocked.
💡 Tip: always start with Content-Security-Policy-Report-Only to measure the impact before blocking anything in production.
To confirm the header is active at any point, a curl to the page is enough:
curl -I https://your-site.com | grep -i content-security-policy
If you’d rather check it from the browser, the DevTools console automatically filters violation messages with the text “Content Security Policy” when something gets blocked, and the Application tab shows the raw header on every response.
Real-World Use Cases
In e-commerce checkouts, CSP limits which scripts can touch the credit card form, reducing the risk of skimming attacks where a compromised third-party script captures payment data before it gets sent.
In internal admin panels, a strict policy with default-src 'none' as a base and minimal permissions added directive by directive reduces the attack surface if an employee opens a malicious link while logged in.
On sites with user-generated content (forums, comments, wikis), CSP is the defense that’s left when an HTML sanitization filter has a bug and lets through a tag it shouldn’t.
In dashboards with third-party widgets (maps, video players, support chats), CSP forces you to explicitly declare each embedded domain, turning a decision that used to be invisible into an auditable list within the server’s own code.
It also serves as a safety net against malicious browser extensions or compromised ad SDKs: if a third-party script tries to load additional code from an unauthorized domain, the policy blocks it regardless of where the original instruction came from.
Common Mistakes and Best Practices
- ‘unsafe-inline’ in script-src: it gets added to make things work quickly and undoes most of the protection against XSS, because it allows any inline script again, injected or not.
- Forgetting connect-src: silently breaks any fetch or XHR call to an API, even one on the same domain but on a different port or subdomain.
- Forgetting img-src data:: breaks base64-embedded images, common in rich text editors and PDF generators.
- Reusing the same nonce across the whole session: defeats its purpose. The nonce needs to be regenerated on every HTTP response, not once per user session.
- Deploying straight into blocking mode: without going through Report-Only first, it’s the fastest way to break a production checkout on a Friday afternoon.
- Blocking browser extensions: some extensions inject their own scripts into the page, and that can also show up in reports as a false positive, without it being an issue with your code.
⚠️ Watch out: ‘unsafe-inline’ in script-src undoes much of the protection against XSS. Use it only as a temporary step while you migrate to nonces or hashes.
Comparison: CSP vs. Other XSS Defenses
| Mechanism | What It Does | When to Use It | Limitation |
|---|---|---|---|
| Content Security Policy | Restricts which origins the browser can load scripts, styles, and images from | As an additional layer on any site with user-generated content | Doesn’t sanitize HTML; if the browser doesn’t support it, it offers no protection |
| Output sanitization | Escapes special characters before inserting user data into HTML | Always, it’s the first line of defense against XSS | A single unescaped point anywhere in the code breaks the protection |
| Trusted Types | Forces a validating function to run before assigning dynamic HTML to the DOM | Apps with a lot of dynamic DOM manipulation in vanilla JavaScript | Limited support, mainly Chromium-based browsers |
| X-XSS-Protection | Used to enable the browser’s heuristic XSS filter | Never: the header is obsolete | Deprecated; Chrome and Edge removed it |
| Framework auto-escaping | Automatically escapes any interpolated value in the template (React, Vue) | By default in any modern component-based app | Breaks if the code uses dangerouslySetInnerHTML or v-html without sanitizing |
Going Deeper: Nonces, Hashes, and strict-dynamic
There are three ways to allow an inline script without using 'unsafe-inline'. The first is the nonce: a random value generated on each server response that gets added to both the header and the script’s attribute. The second is the hash: a sha256 of the script’s exact content, useful when the script doesn’t change between requests. The third is 'strict-dynamic', which tells the browser to trust any script loaded by an already-trusted script (via nonce or hash), which greatly simplifies policies for applications that load bundles dynamically.
The W3C’s CSP Level 3 specification added exactly 'strict-dynamic' and the trusted-types directive, which goes a step further than restricting origins: it forces any assignment to innerHTML, document.write, or similar to first pass through a validating function registered in JavaScript. Without a Trusted Types policy, those assignments throw a runtime exception.
Content Security Policy shouldn’t be confused with Permissions-Policy (formerly Feature-Policy), which is a separate header that controls access to browser APIs like the camera, microphone, or geolocation, not the origin of loaded resources. They’re complementary: a modern app typically sends both headers at once.
For teams with many sites, aggregating CSP reports from different domains into a single dashboard helps spot patterns: the same domain getting blocked across dozens of different sites is usually a noisy browser extension, not a real attack, and that helps prioritize which violations to investigate first.
💭 Key point: Trusted Types goes a step further than CSP: it doesn’t just restrict where scripts come from, it forces validation of any HTML before it gets inserted into the DOM.
flowchart TD
A["default-src 'self'"] --> B{"script-src defined?"}
B -->|"No"| C["inherits default-src"]
B -->|"Yes"| D["uses its own list"]
A --> E{"style-src defined?"}
E -->|"No"| F["inherits default-src"]
E -->|"Yes"| G["uses its own list"]
📖 Summary on Telegram: View summary
Your next step: add Content-Security-Policy-Report-Only to a test endpoint on your project and review the reports for 24 hours before blocking anything in production.
Frequently Asked Questions
What’s the difference between Content-Security-Policy and Content-Security-Policy-Report-Only?
The first actively blocks any resource that doesn’t comply with the policy. The second only logs what would have been blocked, without affecting how the site works; it’s used to test a policy before switching it into blocking mode.
Does CSP replace HTML sanitization?
No. CSP is an additional layer of defense in depth. Input sanitization and output escaping remain the first line of defense against XSS; CSP limits the damage if that first line fails.
Does Content Security Policy work in all browsers?
Modern browsers (Chrome, Firefox, Safari, Edge) support most CSP Level 2 directives and a good part of Level 3. Newer directives like trusted-types have more limited support, mainly in Chromium-based browsers.
Can I use CSP with just a meta tag, without touching the server?
Yes, with <meta http-equiv="Content-Security-Policy" content="default-src 'self'"> in the <head>. The limitation is that some directives, like frame-ancestors or report-to, only work with the actual HTTP header, not with the meta tag.
What is ‘strict-dynamic’ and when should I use it?
It tells the browser to trust any script dynamically loaded by a script already authorized via nonce or hash. It’s useful in applications with modern bundlers that load JavaScript chunks at runtime, where listing each domain by hand would be unmanageable.
Does CSP protect against CSRF attacks?
Not directly. CSRF gets prevented with anti-forgery tokens and the SameSite cookie attribute. CSP can help indirectly with directives like form-action, which restricts which URLs a form can submit to.
References
- MDN Web Docs, Content Security Policy: complete reference of directives, values, and browser compatibility.
- W3C, Content Security Policy Level 3: the official standard specification, includes strict-dynamic and trusted-types.
- OWASP, Content Security Policy Cheat Sheet: guide to best practices and common mistakes when implementing CSP.
- web.dev, Content Security Policy: Google tutorial with progressive rollout examples.
📱 Enjoying this content? Follow @programacion on Telegram for daily tech content in Spanish: quick summaries, fresh content every day. @programacion
Imagen destacada: Foto de Sasun Bughdaryan en Unsplash
0 Comments