Security CSP Web

Content Security Policy: From Zero to A+ in an Afternoon

Most web security advice about CSP goes like this: copy a policy from Stack Overflow, paste it into your server config, check SecurityHeaders.com, collect your A+. This approach produces a header that the scanner scores highly while providing almost no real protection. Let’s talk about why, and how to build a policy that actually does something.

What CSP Is (and Isn’t)

Content Security Policy is a browser-enforced allowlist. You declare which origins are permitted to provide resources — scripts, styles, images, fonts, frames — and the browser refuses to load anything that isn’t on the list. It’s a defense-in-depth control against XSS: even if an attacker injects a <script> tag, the browser won’t execute it if the source isn’t in your policy.

It is not:

  • A firewall. It doesn’t filter incoming requests.
  • A substitute for fixing XSS. It reduces impact; it doesn’t prevent injection.
  • Useful if you set 'unsafe-inline'. That directive is a CSP escape hatch that voids most of the protection.

The Policy That Renders CSP Meaningless

Here’s the most common “CSP” I see in production:

Content-Security-Policy: default-src 'self' 'unsafe-inline' 'unsafe-eval' https:;

Every scanner gives this a passing grade. In practice, it does almost nothing. 'unsafe-inline' means inline scripts and event handlers are allowed — which is the exact vector XSS uses. 'unsafe-eval' means eval(), setTimeout(string), and Function() are allowed. The https: wildcard allows loading scripts from any HTTPS origin.

An attacker who can inject <script>malicious()</script> into your HTML is unaffected by this policy.

A Policy With Actual Teeth

This is the policy this website runs:

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

Let’s go directive by directive.

default-src 'none'

This is the fallback for any resource type not explicitly listed. Setting it to 'none' means the default is deny-all. You then explicitly allow only what you need. This approach is far safer than starting with 'self' and accidentally allowing resource types you didn’t think about.

script-src 'self'

Only scripts served from the same origin may execute. No inline scripts, no eval(), no third-party JS. This is what actually stops XSS: an injected <script src="https://evil.com/payload.js"> will be blocked because evil.com is not 'self'. An inline <script></script> will be blocked because 'unsafe-inline' is absent.

The prerequisite is that all your JavaScript lives in external .js files. No onclick="", no <script> blocks in HTML. This is a good practice anyway — it enforces a clean separation between structure and behaviour.

style-src 'self'

Same logic for CSS. Inline style="" attributes are blocked. This prevents CSS injection attacks, which can be used to exfiltrate data by encoding information in CSS selectors that trigger background-image requests.

frame-ancestors 'none' and X-Frame-Options DENY

Both of these prevent your page from being embedded in an iframe. frame-ancestors is the CSP version; X-Frame-Options is the older header that covers browsers without full CSP support. Set both. This is your clickjacking defence.

form-action 'none'

Prevents form submissions to any origin. If your site has no forms, set this. If it does have forms, restrict it to 'self'. An attacker who modifies a form’s action attribute to point to a data-collection endpoint is blocked by this directive.

base-uri 'self'

Prevents injection of a <base> tag that would redirect all relative URLs to an attacker-controlled origin. Often overlooked, but important on pages with relative asset paths.

Deploying Without Breaking Things

Switching from no CSP to a strict CSP on a live application will break things. The correct sequence:

  1. Audit your resource loading. Use browser DevTools → Network tab. Filter by type (Script, Stylesheet, Img) and note every origin that appears.
  2. Deploy in report-only mode first. Content-Security-Policy-Report-Only sends violation reports without blocking anything. Set up a report endpoint (many SIEM platforms accept CSP reports) and collect violations for a week or two across real traffic.
  3. Fix violations, not the policy. When you see a violation for an inline script, the fix is to move the script to an external file — not to add 'unsafe-inline'. Treat every violation as a code quality issue, not a policy configuration issue.
  4. Switch to enforcement mode. Once the report-only violation rate drops to zero for legitimate resources, flip the header from Report-Only to Content-Security-Policy.

The Third-Party Problem

The hardest part of CSP for most applications is third-party scripts: analytics, chat widgets, A/B testing tools, ad networks. Every one of these requires adding an origin to script-src, which widens the attack surface. A compromised CDN serving your analytics script can now execute arbitrary code in your users’ browsers.

My recommendation: treat every third-party script as a risk and make an explicit decision for each one. If it’s not worth adding to your CSP, it’s not worth running. For scripts you do keep, use Subresource Integrity (SRI) to pin the exact hash of the file, so a compromised CDN can’t swap it for a payload:

<script src="https://cdn.example.com/lib.js"
        integrity="sha384-abc123..."
        crossorigin="anonymous"></script>

Testing Your Policy

After deployment, verify with these tools:

  • SecurityHeaders.com — grades your headers and flags missing directives.
  • CSP Evaluator (Google) — analyses your policy for bypasses, even when it looks strict.
  • Browser DevTools console — CSP violations appear as console errors in real time. Browse your app after deployment and watch for them.

A grade A+ with 'unsafe-inline' present means the scanner is wrong, not that you’re secure. Always use CSP Evaluator in addition to any automated grader — it understands the semantics, not just the presence of directives.