What CSP does and does not do

Content-Security-Policy is a browser-enforced allowlist that tells the page which sources scripts, styles, images, fonts, frames, and network requests are permitted to come from. When the page tries to load or execute something outside that allowlist, the browser blocks it. That is the entire mechanism: it constrains where resources are allowed to originate.

The important framing is that CSP limits the blast radius of cross-site scripting (XSS); it does not prevent XSS. If an attacker injects markup into your page, a good policy can stop the injected <script> from running, stop an inline event handler from firing, and stop stolen data from being POSTed to an attacker-controlled host. But the underlying injection flaw is still there. CSP is a second layer behind output encoding and input handling, not a substitute for them. Treat it as damage control for the day your real defenses fail.

CSP can be delivered two ways, and they are not equivalent. The Content-Security-Policy HTTP response header is the full-strength version. A <meta http-equiv="Content-Security-Policy"> tag works for most fetch-directives but silently ignores several directives that only function over a header. In particular, frame-ancestors (clickjacking protection), report-uri/report-to (violation reporting), and sandbox cannot be set via meta. If you care about clickjacking or monitoring, you must use the header.

Mental model: XSS is the attacker getting code into your page. CSP is what decides whether that code can run and whether it can phone home. Both matter; CSP only addresses the second half.

The checklist

Work through these in order. Each one closes a specific class of abuse.

  • Start from default-src 'none'. Deny everything by default, then add back only the resource types you actually serve. A deny-by-default baseline means a directive you forgot fails closed instead of open.
  • Set script-src without 'unsafe-inline' and without 'unsafe-eval'. This is the single most important directive. Drive inline scripts with per-response nonces or 'sha256-...' hashes, and remove any reliance on eval, new Function, or string-argument setTimeout.
  • Have a style-src strategy. Inline styles are far lower risk than inline scripts, but 'unsafe-inline' on styles still enables some data-exfiltration and UI-redress tricks. Prefer external stylesheets or nonced/hashed style blocks; only fall back to 'unsafe-inline' for styles if a dependency forces it.
  • Set object-src 'none'. Plugins (<object>, <embed>) are a legacy script-execution vector almost nobody needs. Turn them off outright.
  • Set base-uri 'none' or 'self'. Without this, an injected <base> tag can rewrite where every relative URL (including scripts) resolves, defeating an otherwise tight script-src.
  • Set frame-ancestors 'none' (or a strict allowlist). This is your real clickjacking control. It supersedes X-Frame-Options and supports multiple origins, which the older header cannot.
  • Set form-action 'self'. Stops an injected form from submitting credentials or tokens to an external endpoint.
  • Lock connect-src to your API origins. This governs fetch, XMLHttpRequest, WebSocket, and sendBeacon. A tight connect-src is what actually stops fetch-based exfiltration of stolen data even if a script runs.
  • Add upgrade-insecure-requests. Transparently upgrades stray http:// subresource URLs to HTTPS so a single mixed-content reference does not break the page or leak over plaintext.
  • Wire up report-to (and legacy report-uri) for monitoring. You cannot tune a policy you cannot see. Reporting is how you learn what the policy breaks before and after enforcement.

A solid starter policy

This is a reasonably strict policy for a typical app that serves its own scripts and styles and talks to its own API. Adjust the hosts; the structure is the point.

Content-Security-Policy:
  default-src 'none';
  script-src 'self' 'nonce-r4nd0m';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self' https://api.example.com;
  base-uri 'none';
  form-action 'self';
  frame-ancestors 'none';
  object-src 'none';
  upgrade-insecure-requests;
  report-to csp-endpoint;

Line by line:

  • default-src 'none' - deny every resource type unless a more specific directive allows it.
  • script-src 'self' 'nonce-r4nd0m' - scripts may load from your own origin, plus inline scripts carrying the matching per-response nonce.
  • style-src 'self' - stylesheets only from your origin; no inline styles.
  • img-src 'self' data: - images from your origin plus small inline data: images (drop data: if you do not need it).
  • font-src 'self' - fonts only from your origin.
  • connect-src 'self' https://api.example.com - network calls only to your origin and your API.
  • base-uri 'none' - no <base> tag may change relative URL resolution.
  • form-action 'self' - forms may only submit back to your origin.
  • frame-ancestors 'none' - the page may not be framed by anyone (clickjacking off).
  • object-src 'none' - no plugin content.
  • upgrade-insecure-requests - rewrite any http:// subresource to https://.
  • report-to csp-endpoint - send violation reports to the named reporting group.

The nonce value must be unique per response and unpredictable. Generate it server-side for each request and stamp it onto both the header and every inline <script nonce="..."> you control.

Common weak patterns

These are the patterns that make a policy look present while doing little. Each one widens the allowlist back toward "anything goes."

  • 'unsafe-inline' in script-src - re-permits exactly what XSS produces: arbitrary inline scripts and event handlers. With it set, an injected <script> runs normally and the policy buys you almost nothing on the script axis.
  • 'unsafe-eval' - allows eval, new Function, and string timers, turning attacker-controlled strings into executable code. Some legacy template engines need it; treat that as debt to remove, not a default.
  • Wildcard * sources - script-src * or connect-src * permits any host, so exfiltration and remote-script loading work freely. A wildcard is the absence of a policy with extra steps.
  • data: in script-src - lets an attacker run code straight from a data: URI (data:text/javascript,...) without ever touching an allowlisted host. Never allow data: for scripts.
  • Missing frame-ancestors - leaves the page framable and open to clickjacking. Relying only on X-Frame-Options means no multi-origin allowlisting and inconsistent handling across edge cases the newer directive covers.
  • Allowlisting a whole CDN - if that CDN hosts any file that can be coerced into executing attacker input (a JSONP endpoint, an old vulnerable library, an Angular build), the attacker borrows your trust in the CDN to run code. Pin specific paths or use hashes instead of trusting an entire host.
  • https: as a source - script-src https: trusts every HTTPS host on the internet. It blocks plain http and nothing else, which is essentially no restriction for an attacker who controls or finds one usable HTTPS endpoint.

Rollout: report-only to enforced

Shipping a strict CSP straight to enforcement on a live app almost always breaks something - a third-party widget, an inline handler you forgot, a stylesheet on an unexpected host. The safe path is to observe first.

  • Deploy in report-only mode. Send your candidate policy in the Content-Security-Policy-Report-Only header alongside a reporting endpoint. In this mode the browser does not block anything; it only reports what would have been blocked. Your site keeps working while you collect data.
  • Collect from real traffic for about a week. Synthetic testing misses the long tail of browsers, extensions, and user paths. Roughly a week of live traffic surfaces the violations that actually occur.
  • Triage the violations. Separate legitimate breakage (your own widget on a host you forgot to allow) from noise and attacks. Browser extensions and injected adware generate a lot of report spam that is not your code - learn to filter it out so it does not distort the policy.
  • Tighten, do not loosen. Add the specific hosts, nonces, or hashes that legitimate features need. Resist the urge to paper over a cluster of violations with 'unsafe-inline' or a wildcard, which throws away the whole exercise.
  • Switch to enforcement. Once report-only is quiet except for known noise, move the same policy into the enforcing Content-Security-Policy header. Keep a reporting endpoint live afterward so regressions and real attacks still surface.

Nonces and hashes

Removing 'unsafe-inline' means you need another way to permit the inline scripts you genuinely want. There are two mechanisms.

A nonce is a random per-response token you put in both the header (script-src 'nonce-abc123') and the tag (<script nonce="abc123">). The browser runs only inline scripts whose nonce matches. Because the value changes every response and an attacker cannot guess it, injected scripts have no valid nonce and are blocked. Nonces suit server-rendered pages where you can generate a fresh value per request.

A hash permits one exact inline script by its content digest. You compute the SHA-256 of the script's body, base64-encode it, and add it to the policy as 'sha256-...'. The browser hashes each inline script and runs only those whose hash is listed. Hashes suit static inline snippets that do not change, so you can compute the digest once at build time. If even one byte of the script changes, the hash no longer matches and you must regenerate it.

script-src 'self' 'sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc=';

You can generate the base64 SHA-256 digest of an inline snippet with this site's Hash Generator - hash the exact script contents, prefix the result with sha256-, and drop it into the directive.

Related tools

  • CSP Analyzer - paste a policy to spot weak directives and missing protections
  • Hash Generator - compute the SHA-256 digest for a 'sha256-...' script-src entry
  • Secure Paste - share a policy or report snippet without leaving it on a third-party host

Related guides