⏱️ 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
  1. TL;DR
  2. What Content Security Policy Is and Why It Matters
  3. How Content Security Policy Works Internally
  4. Practical Examples: From a Minimal Policy to a Real One
  5. How to Get Started: Concrete Steps to Enable CSP
  6. Real-World Use Cases
  7. Common Mistakes and Best Practices
  8. Comparison: CSP vs. Other XSS Defenses
  9. Going Deeper: Nonces, Hashes, and strict-dynamic
  10. Frequently Asked Questions
    1. What’s the difference between Content-Security-Policy and Content-Security-Policy-Report-Only?
    2. Does CSP replace HTML sanitization?
    3. Does Content Security Policy work in all browsers?
    4. Can I use CSP with just a meta tag, without touching the server?
    5. What is ‘strict-dynamic’ and when should I use it?
    6. Does CSP protect against CSRF attacks?
  11. 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.

HTTP Content-Security-Policy header seen in browser developer tools
The Network tab shows the CSP header on every server response. Foto de Walls.io en Unsplash

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.

Browser console showing a blocked Content Security Policy violation
Chrome logs the blocked origin and the directive that stopped it. Foto de FlyD en Unsplash

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

MechanismWhat It DoesWhen to Use ItLimitation
Content Security PolicyRestricts which origins the browser can load scripts, styles, and images fromAs an additional layer on any site with user-generated contentDoesn’t sanitize HTML; if the browser doesn’t support it, it offers no protection
Output sanitizationEscapes special characters before inserting user data into HTMLAlways, it’s the first line of defense against XSSA single unescaped point anywhere in the code breaks the protection
Trusted TypesForces a validating function to run before assigning dynamic HTML to the DOMApps with a lot of dynamic DOM manipulation in vanilla JavaScriptLimited support, mainly Chromium-based browsers
X-XSS-ProtectionUsed to enable the browser’s heuristic XSS filterNever: the header is obsoleteDeprecated; Chrome and Edge removed it
Framework auto-escapingAutomatically escapes any interpolated value in the template (React, Vue)By default in any modern component-based appBreaks 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

📱 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

Categories: SeguridadTutorials

Clara Vásquez

Cybersecurity analyst focused on critical vulnerabilities, zero-days, and emerging threats. Covers high-impact CVEs, malware analysis, ransomware incidents, and security trends with a LATAM lens.

0 Comments

Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.