EncryptCodecencryptcodec
Blog/Web Security
Web SecurityMarch 30, 2026 · 9 min read

Content Security Policy: A Practical CSP Implementation Guide for Developers

You deploy a new feature, someone files a bug, and the root cause is that your CSP is blocking a legitimate script from a CDN you added last week. You add unsafe-inline to silence the console errors, and now your entire XSS protection is gone. CSP done wrong is worse than useless — here's how to do it right.

Content Security Policy is the most effective browser-side XSS mitigation available, and a strict policy with nonces or hashes can stop the vast majority of injection attacks even when your output encoding has a gap. The key is building the policy incrementally rather than writing it all at once and hoping for the best.

Start in Report-Only Mode

Before you touch a production policy, add a Content-Security-Policy-Report-Only header. This mirrors exactly what a real policy would block, but doesn't actually block anything — it just sends violation reports to an endpoint you control.

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

Run this in production for at least a week. Look at the violation reports. You will find third-party scripts, inline event handlers, and eval() calls you didn't know existed. Every violation is a potential bypass you would have opened up if you'd gone straight to enforcement.

Your violation endpoint just needs to accept POST requests with JSON. Log them to your existing observability stack and set up an alert for new source origins you haven't whitelisted.

Build the Policy Directive by Directive

Don't write a one-liner policy. Build it directive by directive based on what your app actually needs.

CSP POLICY BUILDER

Select a value for each directive to see your policy take shape.

policy looks safe
default-srcFallback for any directive not explicitly defined.
Only allow resources from your own origin.
script-srcControls which scripts can execute. Most critical directive.
Best practice: per-request nonce propagates trust to loaded scripts.
style-srcControls stylesheets. CSS injection is lower risk than script injection.
Acceptable for styles — CSS injection risk is lower than script.
connect-srcControls fetch(), XHR, and WebSocket connections.
Only your own API. Missing an entry causes silent fetch failures.
frame-ancestorsPrevents clickjacking. Replaces X-Frame-Options.
No site can embed your page in a frame. Strongest protection.
ADDITIONAL HARDENING DIRECTIVES
Prevents <base> tag injection attacks. Stops form-based exfiltration.
Content-Security-Policy
default-src 'self'; script-src 'nonce-...' 'strict-dynamic'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';

default-src 'self' is your starting point. It sets the fallback for any directive you don't explicitly define. Everything else is additive.

script-src is where most of the security value lives. The goal is to avoid unsafe-inline entirely. If your app has inline <script> tags or onclick attributes, you have two options: nonces or hashes.

A nonce looks like this in your HTML:

<script nonce="r4nd0mV4lu3">/* your code */</script>

And in your CSP header:

script-src 'nonce-r4nd0mV4lu3' 'strict-dynamic'

'strict-dynamic' is important here — it propagates trust to scripts loaded by your trusted scripts, which means you don't have to whitelist every CDN your bundle loader touches.

style-src follows the same logic as script-src. Inline styles used by CSS-in-JS libraries (styled-components, Emotion) will trip you up here. Nonces work for <style> tags. If you're on a framework that injects styles at runtime, you may need unsafe-inline for styles — this is acceptable since CSS injection is lower risk than script injection, but don't carry that same compromise into script-src.

img-src should be 'self' data: https: for most apps. The data: allowance is necessary for base64-encoded images used by charting libraries and rich text editors.

connect-src controls fetch, XHR, and WebSocket connections. List every API domain your frontend talks to. Missing one here causes silent failures in production that are a nightmare to debug — the request just fails with no visible error to the user.

frame-ancestors is not the same as X-Frame-Options, but use it to replace that header. frame-ancestors 'none' prevents clickjacking.

Generating Nonces Correctly

The single most common mistake with nonce-based CSP is generating a weak or reused nonce. A nonce must be:

  • Cryptographically random (not Math.random())
  • At least 128 bits (16 bytes)
  • Base64-encoded
  • Unique per HTTP response — not per session, per response
import crypto from 'crypto';

// In your middleware, before rendering
function generateNonce() {
return crypto.randomBytes(16).toString('base64');
}

// Express middleware example
app.use((req, res, next) => {
res.locals.cspNonce = generateNonce();
res.setHeader(
  'Content-Security-Policy',
  `script-src 'nonce-${res.locals.cspNonce}' 'strict-dynamic'; default-src 'self'; frame-ancestors 'none';`
);
next();
});

The unsafe-inline Trap in SPAs

Here's the edge case that catches almost every React or Angular developer: 'strict-dynamic' does not work in browsers that don't support it (IE11), but more practically, some bundlers inject inline scripts during the build that don't have nonces. Create React App and older Angular CLI setups are frequent offenders.

The fix is to hash those injected scripts rather than adding unsafe-inline. Run your app, collect the violation reports, and for any inline script you can't nonce (because it's injected by your build tool), compute its SHA-256 hash and add it to the policy:

script-src 'nonce-abc123' 'sha256-BASE64_OF_SCRIPT_CONTENT' 'strict-dynamic'

The hash must be of the exact script content including whitespace. One extra space breaks it. If your build tool regenerates that inline script with a different content hash on every build, you'll need to automate hash extraction as part of your CI pipeline.

CSP and Next.js

Next.js has a specific pattern for nonces because the App Router renders server-side. As of Next.js 13+, you set the nonce in a middleware file and pass it via headers. The next.config.js needs to be updated so the framework doesn't strip your nonce from injected scripts — specifically, you need to set dangerouslyAllowSVG and configure the nonce in the headers() function or via the middleware approach documented in their security headers guide.

The critical detail people miss: Next.js in production generates static HTML for pages using generateStaticParams. Static pages can't have per-request nonces because there's no server context at render time. For fully static pages, fall back to hashes or accept that script-src for those routes will need a different policy.

What a Strict Production Policy Looks Like

A reasonable starting point for a server-rendered app with a trusted CDN:

Content-Security-Policy:
  default-src 'self';
  script-src 'nonce-GENERATED_PER_REQUEST' 'strict-dynamic';
  style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
  font-src 'self' https://fonts.gstatic.com;
  img-src 'self' data: https:;
  connect-src 'self' https://api.yourdomain.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  upgrade-insecure-requests;

base-uri 'self' is easy to miss — it prevents attackers from injecting a <base> tag that redirects all relative URLs to an attacker-controlled domain. form-action 'self' stops exfiltration via form hijacking. Both are one-line additions with no downside.

Open the CSP Generator tool on EncryptCodec right now, paste in your domain and the third-party origins your app uses, and generate a baseline policy. Then run it in Report-Only mode against your staging environment before you touch production.

Share this post

Try the XSS Simulation on EncryptCodec

Free, browser-based — no signup required.

Frequently Asked Questions

Related posts