Remediation guide

How to Add a Content-Security-Policy Header (Step-by-Step)

HardenCheck flagged your site for a missing or weak Content-Security-Policy header. This guide explains what CSP does, walks you through building a real policy from scratch, shows how to test it safely before it can break anything, and gives you the exact deployment snippet for every major platform.

Don't enforce a policy cold. Deploying a strict CSP without testing first is the fastest way to break your own site. Read the report-only section below before you add a single directive to a production header.

What CSP does and why it matters

Cross-site scripting (XSS) is consistently the most exploited vulnerability class on the web. An attacker who can inject HTML into your page — through a stored comment, a reflected query parameter, or a compromised third-party widget — can execute arbitrary JavaScript in your users' browsers: stealing session tokens, redirecting to phishing pages, or silently exfiltrating form data. Sanitising input is your first line of defence, but it is imperfect. Libraries have bugs, developers make mistakes, and third-party integrations can introduce injection points you don't control.

A Content-Security-Policy header is the browser-level safety net that limits what scripts can run even after an attacker has found a way in. Rather than trusting every script that appears on the page, the browser asks: "did the server explicitly allow this?" If the script's origin, nonce, or hash doesn't match the policy, it is silently blocked before it ever executes. A well-written CSP turns a successful XSS injection into a non-event. It does not replace input sanitisation, but it dramatically raises the bar for what a successful injection can actually do.

Start with report-only mode

The single most important piece of advice in this guide: use Content-Security-Policy-Report-Only before you use Content-Security-Policy. Report-only sends the same header with the same policy, but the browser only reports violations — it doesn't block anything. Your site keeps working exactly as it does today. You learn, for free, every resource your policy would have blocked.

Add this header to your server's response (the policy is intentionally strict — that's the point):

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'

Then open your site in a browser, navigate through every key page — including any page that loads analytics, chat widgets, fonts, or third-party embeds — and open DevTools (F12) → Console. Every violation appears as a warning beginning with "Refused to load the script…" or similar. Each one tells you exactly which domain or inline resource the policy would have blocked. Collect those, update your policy to allow what is legitimate, and repeat until the console is quiet. Only then switch from Content-Security-Policy-Report-Only to the enforcing Content-Security-Policy header.

Build a starter policy

A minimal but real policy for a server-rendered site looks like this. Each directive is on its own line for clarity — in your actual header value, the directives are separated by semicolons on one line.

Content-Security-Policy:
  default-src 'self';          /* fallback: only load from your own origin */
  script-src 'self';           /* JS only from your own origin (add nonces for inline) */
  style-src 'self';            /* CSS only from your origin (add fonts.googleapis.com if needed) */
  img-src 'self' data:;        /* images from your origin + inline data: URIs for SVGs */
  connect-src 'self';          /* fetch/XHR/WebSocket to your own origin only */
  font-src 'self';             /* web fonts from your origin (add fonts.gstatic.com if needed) */
  frame-ancestors 'none';      /* prevents your page from being iframed (blocks clickjacking) */

A few things to understand before you ship this. default-src 'self' is the catch-all fallback — any directive you omit inherits this value. frame-ancestors 'none' is the CSP equivalent of X-Frame-Options: DENY and is the modern way to prevent clickjacking; if you intentionally embed your page in an iframe from a specific partner domain, use frame-ancestors https://partner.com instead.

Note what is not in this policy: 'unsafe-inline'. Adding 'unsafe-inline' to script-src allows any inline <script> block on your page to execute — including any one an attacker injected. It eliminates the most important protection CSP provides against XSS. If your site currently relies on inline scripts, the right fix is nonces, not 'unsafe-inline'.

Eliminate unsafe-inline with nonces

A nonce (number used once) is a random, unpredictable token generated fresh on every request. You include it in your CSP header and in the nonce attribute of each legitimate inline script. The browser only executes inline scripts that carry the matching nonce — and since the nonce changes on every request, an attacker who injects a script tag cannot know the current nonce and cannot make their script execute.

The header (with a new random nonce per request, typically base64-encoded):

Content-Security-Policy: script-src 'self' 'nonce-r4nd0mBas364EncodedToken';

The matching script tag in your HTML:

<script nonce="r4nd0mBas364EncodedToken">
  // This inline script is allowed because the nonce matches
  initAnalytics();
</script>

Nonces require server-side rendering or a build step that injects a fresh nonce into both the header and the HTML on every request. A Node/Express example:

const crypto = require('crypto');

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString('base64');
  res.setHeader(
    'Content-Security-Policy',
    `script-src 'self' 'nonce-${res.locals.nonce}'`
  );
  next();
});

// In your template: <script nonce="<%= nonce %>">...</script>

For fully static sites where you can't inject a per-request nonce, use a hash instead. Generate a SHA-256 hash of the exact script content and include it in the policy:

# Generate the hash of a specific inline script
echo -n "initAnalytics();" | openssl dgst -sha256 -binary | base64

# Then include it in your policy:
Content-Security-Policy: script-src 'self' 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='

Hashes work well when the inline script content never changes (e.g., a fixed analytics snippet). If the script content changes, the hash must be regenerated and redeployed.

Deploy it on your platform

nginx

add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none';" always;

Apache (.htaccess)

<IfModule mod_headers.c>
  Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none';"
</IfModule>

Vercel (vercel.json)

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Content-Security-Policy",
          "value": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none';"
        }
      ]
    }
  ]
}

Next.js (next.config.js)

const securityHeaders = [
  {
    key: 'Content-Security-Policy',
    value: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none';"
  }
];

module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: securityHeaders,
      },
    ];
  },
};

Netlify (_headers file)

/*
  Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none';

Cloudflare Pages (_headers file)

/*
  Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none';

Common things that break

These are the violations you will almost certainly see in the DevTools console when you first run in report-only mode.

  • Google Fonts. The stylesheet loads from fonts.googleapis.com and the actual font files from fonts.gstatic.com. Add both: style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com;
  • Analytics and tag managers. Google Tag Manager loads scripts from https://www.googletagmanager.com; Google Analytics sends data to https://www.google-analytics.com. Add the script origin to script-src and the telemetry endpoint to connect-src. Be specific — https://www.googletagmanager.com is better than https://*.google.com.
  • CDN-hosted scripts. If you load a UI library or widget from a CDN (jsDelivr, unpkg, cdnjs), that CDN domain needs to appear in script-src. Inline eval() calls inside those libraries will require 'unsafe-eval' — avoid libraries that need it if you can.
  • Inline SVGs using data: URIs. If your CSS or HTML references images as data: URIs (common with inline SVG icons), allow them with img-src 'self' data:. The starter policy above already includes this.
Keep your allowlist tight. Every domain in script-src is a domain whose scripts your browser will trust and execute on your users' behalf. If that CDN or analytics provider is ever compromised, so is your site. Prefer self-hosting critical scripts where feasible, and review your allowlist periodically.

Verify with HardenCheck

Once you've deployed your policy — or while you're still building it — paste your raw response headers into HardenCheck's paste mode. It will grade your Content-Security-Policy header, flag common weaknesses like a missing default-src, the presence of 'unsafe-inline' or 'unsafe-eval', and whether frame-ancestors is set. You can iterate on the policy without deploying anything: paste updated header values and watch the grade improve in real time.

Grade my headers with HardenCheck →

Frequently asked questions

What's the difference between Content-Security-Policy and Content-Security-Policy-Report-Only?

Report-Only logs violations to the console (or a report-uri endpoint) without blocking anything; the browser acts as if the policy were enforced and records what would have been blocked, but your site keeps working normally. Use it first to learn what your policy would break before switching to the enforcing Content-Security-Policy header.

Does CSP replace HTTPS?

No. CSP controls what resources the browser will load and execute. HTTPS (enforced via HSTS) controls whether the transport is encrypted. They defend against different threats — a man-in-the-middle attacker intercepting traffic versus an injected script running on the page. You need both; neither substitutes for the other.

Why does 'unsafe-inline' make CSP almost useless for XSS?

The main threat CSP defends against is an attacker injecting a <script>stealData()</script> block into your page. If you allow 'unsafe-inline', that injected script runs — the browser has no way to distinguish it from your own inline scripts. The policy still blocks remote script imports from unexpected domains, so it isn't entirely worthless, but it eliminates the most important protection CSP provides. Use nonces or hashes to allow your own inline scripts without opening that door.

My site uses a third-party analytics tag — do I need to allow it?

Yes. Any script loaded from a third-party domain needs that domain in your script-src. Check your browser's Network tab to see every domain scripts are fetched from — analytics tools, chat widgets, A/B testing platforms, and payment embeds all typically load from their own CDNs. Be specific: prefer exact origins like https://www.googletagmanager.com rather than wildcards like https://*, which would allow scripts from any HTTPS source.

Will CSP break my site?

It can, if you enforce it without testing first. That's exactly why report-only mode exists. Run with Content-Security-Policy-Report-Only for at least a few days, covering all your important page flows, and watch the browser console for violations. Update the policy to allow each legitimate resource, then switch to enforcing. Never copy-paste a strict policy and deploy it to production cold without this step.

Where can I test my CSP policy before deploying?

Paste your headers — including the Content-Security-Policy line — into HardenCheck's paste mode. It will grade the policy, flag missing directives, and call out dangerous values like 'unsafe-inline'. You can also use your browser's DevTools console in report-only mode to see real violations against your actual page as you navigate it.

Also in the Copper Bay Labs ship-safety suite

A missing CSP is one of several issues to close before you ship. These free tools cover the rest: