How to Implement Content Security Policy Headers Correctly in 2026

Why Content Security Policy Headers Still Matter in 2026

Cross-site scripting (XSS) remains one of the most exploited vulnerabilities on the web, and content security policy headers are still the most effective browser-native defense against it. A well-crafted CSP tells the browser exactly which scripts, styles, images, and connections it should trust, and blocks everything else by default.

The problem? Most CSPs deployed in production are either too permissive (using unsafe-inline and unsafe-eval, which defeats the purpose) or so strict they break the site on day one. This guide walks you through a hands-on migration from a loose CSP to a strict, nonce-based one, with copy-paste examples for Nginx and Express.

web security shield code

What a Content Security Policy Header Actually Does

The Content-Security-Policy HTTP response header is sent by your server on every page request. The browser parses it and enforces the rules before executing any resource. If a script, iframe, or fetch call violates a directive, the browser refuses to load or execute it.

Here is a minimal example:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'

That one line blocks inline scripts, external scripts from untrusted origins, Flash-style plugins, base tag hijacking, and clickjacking. Not bad for a single header.

web security shield code

Core CSP Directives You Need to Know

Directive Purpose Recommended Value
default-src Fallback for all fetch directives 'self'
script-src Controls JavaScript sources 'self' 'nonce-{random}' 'strict-dynamic'
style-src Controls CSS sources 'self' 'nonce-{random}'
img-src Controls image sources 'self' data: https:
connect-src Controls fetch, XHR, WebSocket 'self' https://api.yourdomain.com
font-src Controls font sources 'self' https://fonts.gstatic.com
frame-ancestors Anti-clickjacking (replaces X-Frame-Options) 'none' or 'self'
base-uri Restricts <base> tag 'self'
object-src Blocks plugins 'none'
form-action Restricts form submission targets 'self'
upgrade-insecure-requests Auto-upgrades HTTP to HTTPS (no value)

The 5-Step Migration Plan from Permissive to Strict CSP

  1. Audit what your site currently loads (scripts, styles, fonts, APIs, images).
  2. Deploy in Report-Only mode to collect violations without breaking anything.
  3. Fix inline scripts and styles by moving them to files or adding nonces.
  4. Switch to enforcement mode once reports are clean.
  5. Iterate toward a strict CSP with nonces and strict-dynamic.

Step 1: Audit Your Site

Open DevTools, go to the Network tab, and list every domain your pages load. Also grep your codebase for <script>, onclick=, style=, and eval(. Every one of these is a future CSP violation.

Step 2: Start with Report-Only

Use the Content-Security-Policy-Report-Only header to log violations without blocking anything:

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-to csp-endpoint

Set up a report-to endpoint (or use a service like Report URI or Sentry) to collect JSON reports. Run this for at least a week to catch edge cases. See mozilla.org for their take.

Step 3: Kill Inline Code with Nonces

A nonce is a random token generated per request. You add it to the CSP header and to every trusted inline script or style tag. Attackers cannot guess the nonce, so injected scripts get blocked.

Express Example with Nonces

const crypto = require('crypto');
const express = require('express');
const app = express();

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString('base64');
  res.setHeader(
    'Content-Security-Policy',
    `default-src 'self'; ` +
    `script-src 'self' 'nonce-${res.locals.nonce}' 'strict-dynamic'; ` +
    `style-src 'self' 'nonce-${res.locals.nonce}'; ` +
    `img-src 'self' data: https:; ` +
    `connect-src 'self'; ` +
    `object-src 'none'; ` +
    `base-uri 'self'; ` +
    `frame-ancestors 'none'; ` +
    `form-action 'self'; ` +
    `upgrade-insecure-requests`
  );
  next();
});

app.get('/', (req, res) => {
  res.send(`<!doctype html><html><head>
    <script nonce="${res.locals.nonce}">console.log('trusted');</script>
  </head><body>Hello</body></html>`);
});

app.listen(3000);

If you use a template engine like EJS or Pug, pass nonce to the view and inject it into every inline tag. See owasp.org for their take.

Nginx Example

Nginx cannot generate per-request nonces natively, so you either offload nonce generation to your app or use a static (but still strict) CSP for pure static sites:

server {
    listen 443 ssl http2;
    server_name yourdomain.com;

    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    location / {
        root /var/www/yourdomain;
        try_files $uri $uri/ =404;
    }
}

For dynamic apps behind Nginx, let the upstream app (Node, PHP, Python) set the CSP header and use proxy_pass_header so Nginx does not strip it.

Step 4: Enforce the Policy

Once your report endpoint is quiet for a few days, rename the header from Content-Security-Policy-Report-Only to Content-Security-Policy. Keep the report-to directive so you catch regressions.

Step 5: Go Strict with strict-dynamic

Modern strict CSPs use this pattern:

script-src 'nonce-{random}' 'strict-dynamic' https: 'unsafe-inline';

Yes, 'unsafe-inline' is in there, but it is ignored by browsers that support 'strict-dynamic'. It only exists as a fallback for legacy browsers. This approach is recommended by Google and OWASP because it stops relying on domain allowlists (which are notoriously bypassable).

web security shield code

Common Pitfalls to Avoid

  • Using unsafe-inline permanently: it neutralizes CSP against XSS. Use nonces instead.
  • Wildcarding script-src *: equivalent to no CSP at all for scripts.
  • Forgetting base-uri: attackers can inject a <base> tag to hijack relative URLs.
  • Forgetting frame-ancestors: you lose clickjacking protection.
  • Reusing the same nonce across requests: it must be regenerated on every response.
  • Applying CSP only to HTML pages: also protect JSON and API responses with default-src 'none'.
  • Ignoring browser extensions in reports: filter out chrome-extension:// and moz-extension:// noise.
  • Setting CSP in a <meta> tag: some directives (like frame-ancestors and report-to) only work as HTTP headers.

Testing Your CSP

  1. Use Google CSP Evaluator to score your policy.
  2. Use Mozilla Observatory for a full security header audit.
  3. Check DevTools Console for Refused to execute or Refused to load messages.
  4. Run automated end-to-end tests with the enforced policy in a staging environment.
web security shield code

Recommended Strict CSP Template for 2026

Content-Security-Policy:
  default-src 'none';
  script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
  style-src 'self' 'nonce-{RANDOM}';
  img-src 'self' data: https:;
  font-src 'self' https://fonts.gstatic.com;
  connect-src 'self' https://api.yourdomain.com;
  form-action 'self';
  frame-ancestors 'none';
  base-uri 'self';
  object-src 'none';
  upgrade-insecure-requests;
  report-to csp-endpoint

FAQ

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

The enforcement header blocks violations. The report-only variant logs them without blocking, which is ideal for testing new policies safely in production.

Can I set multiple Content-Security-Policy headers?

Yes. Browsers apply the intersection of all policies, meaning the strictest rule wins. This is useful when a CDN adds a baseline policy and your app adds page-specific tightening.

Are nonces better than hashes?

Nonces are more flexible for dynamic content because they change per request. Hashes are better for static inline snippets that never change. Many sites use both.

Does X-Content-Security-Policy still work?

No. X-Content-Security-Policy and X-Webkit-CSP are legacy headers from early browser implementations and should be removed. Use only Content-Security-Policy.

Will CSP slow down my site?

No measurable performance impact. The header adds a few hundred bytes and browsers parse it once per response.

How do I handle third-party scripts like analytics or ads?

Prefer 'strict-dynamic' so trusted loaders can pull in their own dependencies without you allowlisting every domain. If you must allowlist, list specific subdomains, not wildcards.

Wrapping Up

Deploying content security policy headers correctly is not about copying a snippet from Stack Overflow. It is a staged migration: audit, report, refactor, enforce, and iterate. Start in report-only mode today, kill your inline scripts this week, and by next month your site will have one of the strongest XSS defenses available in a modern browser. threatngsecurity.com has a solid rundown on this.

Bookmark this guide, test with Google CSP Evaluator, and if you deploy to Nginx or Express, the snippets above are production-ready. Ship it.