DevSwitch.net

Content Security Policy Headers: Preventing XSS Attacks

Illustration of a Content Security Policy protecting a web page, with a shield blocking XSS attack fragments while allowing trusted content through.

Content Security Policy headers are HTTP response headers that tell the browser exactly which sources of scripts, styles, images, and other content it's allowed to load and run. Their main job is stopping cross-site scripting (XSS) attacks: even if an attacker manages to inject malicious JavaScript into your page, a properly configured CSP tells the browser to refuse to execute it. You set them by sending a Content-Security-Policy header with directives like script-src 'self', and the browser enforces those rules on every page load.

How CSP stops XSS attacks

Cross-site scripting works because a browser can't naturally tell the difference between a script you wrote and a script an attacker slipped into your page through a comment box, URL parameter, or vulnerable form field. Both look like valid JavaScript, so the browser runs both.

A Content Security Policy flips that default. Instead of trusting every script on the page, the browser only runs scripts from sources you explicitly approve. If an attacker injects <script>steal(document.cookie)</script> into your page but your CSP only allows scripts from your own domain, the browser blocks the injected code and logs a violation.

CSP is a defense-in-depth layer. It doesn't fix the underlying injection bug, so you still need proper input sanitization and output encoding among your xss prevention techniques. But when your other defenses fail, CSP is often the thing that stops a working exploit from ever executing.

The core CSP directives you actually use

A policy is a list of directives, each controlling one type of resource. These are the ones you'll reach for most:

Directive Controls Typical value
default-src Fallback for any resource type not set explicitly (base-uri, form-action and frame-ancestors do not fall back to it, so set them yourself) 'self'
script-src Where JavaScript can load from (the key XSS defense) 'self'
style-src Where CSS can load from 'self'
img-src Where images can load from 'self' data:
connect-src Where fetch, XHR, and WebSocket can connect 'self'
frame-ancestors Who can embed your page in an iframe 'none'

The script-src directive does the heavy lifting for XSS prevention. Common source values you'll combine with it:

  • 'self' — scripts from your own origin only.
  • 'none' — block everything for that directive.
  • https://cdn.example.com — a specific trusted host.
  • 'nonce-abc123' — a one-time random token that must match the script tag's nonce attribute.
  • 'sha256-...' — a hash of an exact inline script's contents.

Writing your first policy

A solid starting policy locks everything to your own origin and only opens up what you genuinely need. Here's a reasonable baseline:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self' data:;
  connect-src 'self';
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self'

Read it left to right: everything defaults to your own domain, scripts and styles come only from your origin, the img-src CSP directive also allows data: URIs (handy for inline SVGs and small graphics), nobody can iframe your page, and forms can only submit back to you. That last part, form-action 'self', quietly blocks a whole class of attacks where injected markup tries to post your form data to an attacker's server.

Send it as a header, not a meta tag when you can. The Content-Security-Policy HTTP header supports every directive. A <meta> tag version exists but can't use frame-ancestors or report directives, so the header is the stronger choice.

The unsafe-inline problem and how to fix it

Here's where most people trip. Real sites have inline scripts, inline event handlers like onclick="...", and inline <style> blocks. A strict CSP blocks all of those, because inline code is exactly what an XSS injection produces.

The tempting fix is adding 'unsafe-inline' to script-src. Don't. It reopens the exact hole CSP was meant to close, letting any injected inline script run. Nonces and normal hashes do not cover inline event handlers such as onclick="...", because a nonce goes on a script tag and cannot go on an attribute, so move those handlers into your script files with addEventListener. For inline <script> blocks, allow only your own using one of these:

  • Nonces — generate a fresh random value on every request, add it to the header as 'nonce-RANDOM', and put the same value on each legitimate script tag: <script nonce="RANDOM">. An attacker can't guess the nonce, so their injected script has none and gets blocked.
  • Hashes — compute a sha256 hash of an inline script's exact contents and list it in the policy. The browser runs only scripts whose hash matches. Best for static scripts that never change.
  • Move code to external files — the cleanest option. External .js files loaded from 'self' need no nonce or hash at all.
If you use a nonce, it must be cryptographically random and unique per response. A reused or predictable nonce is no better than unsafe-inline.

For the style-src CSP directive, inline styles are lower risk than scripts, but the same rules apply. Many teams allow 'unsafe-inline' for styles as a pragmatic compromise while keeping scripts strict, since CSS injection is far harder to weaponize than script injection.

Test safely with report-only mode

Shipping a strict policy blind will break things: blocked analytics, a widget that won't load, a font that vanishes. Use Content-Security-Policy-Report-Only first. It runs your policy without enforcing it and reports every violation instead of blocking:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self';
  report-uri /csp-violations

The /csp-violations path only collects anything once you build an endpoint on your server that accepts the violation reports the browser sends; violations also show up in the browser's developer console either way. Run it in report-only mode for a week or two, watch the violation reports roll in, and add the legitimate sources you missed. Once the reports go quiet, switch the header name to the enforcing Content-Security-Policy. You can read the full directive reference on MDN's CSP documentation , and the formal spec lives at the W3C CSP Level 3 page.

Common mistakes that break your site

  • Forgetting third-party scripts. Analytics, tag managers, chat widgets, and payment scripts all load from external domains. Each needs its host added to script-src and often connect-src too. For tag managers and other scripts that load more scripts, a better route is a nonce plus 'strict-dynamic': scripts trusted through the nonce can load further scripts without you listing every host in advance, which is the approach web.dev recommends for a strict CSP.
  • Wildcarding too broadly. script-src * or script-src https: allows scripts from anywhere on the web, which defeats the purpose. Name specific hosts.
  • Leaving unsafe-inline in production. It's fine as a temporary bridge, but it makes your script-src CSP essentially decorative against XSS.
  • Missing base-uri. Without it, an attacker can inject a <base> tag to redirect all your relative script URLs to their server.
  • Only setting CSP and skipping other security headers. Pair it with X-Content-Type-Options: nosniff, Strict-Transport-Security, and Referrer-Policy for layered protection.

Start strict, test in report-only mode, and open up only what your reports prove you need. A tight script-src backed by nonces or hashes is one of the strongest, cheapest defenses you can add against XSS.

No. CSP is a defense-in-depth layer that catches attacks your other defenses miss. You still need server-side input validation and output encoding. Think of CSP as a safety net: if a bug lets an injection through, CSP stops the browser from actually executing the malicious script.

A nonce is a random token generated fresh per request and matched against a script tag's nonce attribute, ideal for dynamic pages. A hash is a fixed fingerprint of an inline script's exact contents, ideal for static scripts that never change. Both let approved inline code run while blocking injected code.

Adding 'unsafe-inline' to script-src tells the browser to run any inline script, including ones an attacker injects. That removes the main protection CSP offers against XSS. Use nonces, hashes, or external files instead so only your own inline scripts are allowed to execute.

Prefer the HTTP header whenever you control the server response. The header supports every directive, including frame-ancestors and violation reporting, which the meta tag version cannot use. A meta tag is a fallback for static hosting where you can't set custom headers.

Deploy it first with the Content-Security-Policy-Report-Only header, which reports violations without blocking anything. Watch the reports for a week or two, add the legitimate sources you missed, and once the reports go quiet, switch to the enforcing Content-Security-Policy header.