Back to issues
SECURITY

CSP: The Last Line of Defense You're Probably Configuring Wrong

Content Security Policy is the security layer that blocks XSS even when the attacker manages to inject HTML. Learn how to configure CSP with nonce, strict-dynamic, and without unsafe-inline.

By Thiago Saraiva9 MIN

Mental Model: CSP is the bouncer at an exclusive club. Your server hands the browser a VIP list ("only these scripts get in, and only from these origins"). Every time a <script>, stylesheet, or fetch() tries to walk through the door, the bouncer checks the list. No name on the list? No entry. No excuses. The rest of this post is just figuring out how to write a good VIP list without accidentally banning your own employees.

You sanitized inputs, escaped outputs, and use a modern framework that does it all automatically. Then one day someone finds a way to inject a <script> into your HTML. Without CSP, that script runs with all the privileges of your site. With CSP, the browser checks the list, sees it's not there, and blocks it.

Content Security Policy is a contract between server and browser: "only execute code and load resources from these specific origins." Anything that violates the contract gets blocked and reported.

A Story: The Breach That Wasn't

In 2018, British Airways leaked payment data from around 380,000 customers. Magecart attackers modified a JavaScript file the airline already loaded on its checkout page (a customized Modernizr script hosted on baways.com), added 22 lines that skimmed the credit card form, and exfiltrated data to the same baways.com typo domain (a lookalike of britishairways.com, registered with a real SSL cert to look legit). The ICO's initial intent-to-fine was 183 million pounds; after appeal and pandemic-era reductions, the final penalty landed at 20 million pounds in October 2020.

A CSP with connect-src 'self' https://api.britishairways.com would have stopped the exfiltration cold. The malicious script still loads (it sat on a trusted origin), but the moment it tries to POST stolen data to baways.com, the browser blocks the request and fires a violation report straight to the security team.

That's the whole point: CSP assumes your other defenses will eventually fail. The bouncer doesn't trust that nobody got past the front gate. He checks again at every door.

How It Works in Practice

The server sends an HTTP header:

Content-Security-Policy: script-src 'self' https://cdn.example.com

The browser checks every resource on the page:

  • <script src="https://cdn.example.com/app.js"> - Allowed
  • <script src="https://malicious.com/xss.js"> - Blocked
  • <script>alert('xss')</script> - Blocked (inline)
  • onclick="doSomething()" - Blocked (inline)
  • eval("code") - Blocked

The bouncer doesn't care how the script got onto the page. Injected via XSS? Snuck in via a compromised npm package? Planted by a rogue browser extension? Not on the list, doesn't run.

The 3 CSP Strategies

1. Nonce-based (recommended for SSR)

The server generates a random nonce per request and marks each legitimate script:

In the HTML:

Think of the nonce as a one-time wristband at that club. New color, new pattern every night. Even if someone photographs yesterday's wristband, it's useless tonight.

strict-dynamic is what makes this powerful: scripts loaded by trusted scripts inherit the trust. Without it, you'd need to list every CDN.

2. Hash-based (for static inline)

If you have inline scripts that never change, use the content hash:

Content-Security-Policy: script-src 'sha256-abc123...'

Any change (even a space) invalidates the hash. Good for analytics snippets and boot scripts. Painful for anything dynamic.

Generate one quickly with openssl:

3. Allowlist-based (avoid!)

Content-Security-Policy: script-src 'self' https://cdn.jsdelivr.net

Looks secure, but Google's research found 94.68% of allowlist-based policies are bypassable (Weichselbaum et al., 2016, still cited in MDN today). If the CDN hosts any vulnerable JSONP endpoint, AngularJS build, or open redirect, the allowlist falls. It's like putting "anyone from Google" on the VIP list. That's a few billion people.

The Directives You Need to Know

  • default-src - Fallback for everything. Start with 'none' and open up.
  • script-src - JS scripts. The most important one.
  • style-src - CSS.
  • img-src - Images.
  • connect-src - fetch, XHR, WebSocket, EventSource, navigator.sendBeacon. Forget this and your app breaks in production.
  • object-src - Always 'none'. Flash/Java objects are classic attack vectors.
  • base-uri - Always 'self'. Without this, an attacker can hijack your relative scripts via injected <base>.
  • frame-ancestors - Who can embed your page (replaces X-Frame-Options).

The Recipe I Use on New Projects

default-src 'none';
script-src 'nonce-{random}' 'strict-dynamic';
style-src 'nonce-{random}';
img-src 'self';
font-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;

Start restrictive, relax as needed. Much easier to open up than to lock down later.

Migrating an Existing Site to CSP

This is where most teams freeze. The app has 40 inline handlers, 12 third-party scripts, and nobody remembers why half are there. Don't try to write the perfect policy on day one. Phase it.

Step 1 - Inventory, don't code. Open DevTools, Network tab, click around the app, list every domain that loads a script, style, image, font, or XHR. That's your starting allowlist. Ugly, but honest.

Step 2 - Deploy the loosest policy possible in Report-Only. Something like default-src 'self' https:; script-src 'self' 'unsafe-inline' https:; report-to csp-endpoint. Yes, it's terrible. The point isn't security yet, it's collecting violation reports from real users on real browsers for 1-2 weeks.

Step 3 - Tighten iteratively. Read the reports. Each violation is either (a) a legitimate resource you forgot, add it; or (b) something suspicious, investigate. Replace 'unsafe-inline' with nonces. Replace https: with specific origins. Drop what you don't need.

Step 4 - Refactor inline handlers. Replace onclick="foo()" with addEventListener. Move inline <style> blocks to external files or nonce them. The boring slog. Also where 80% of the security gain lives.

Step 5 - Flip to enforcement. Change the header name from Content-Security-Policy-Report-Only to Content-Security-Policy. Keep reporting on. Monitor for a week. Done.

Don't skip Step 2. I've seen teams write the perfect policy from code review alone and ship it. They roll back within the hour because checkout broke on Safari for users on some A/B test nobody remembered.

Mistakes I've Seen in Production

CSP that protects nothing:

default-src *; script-src * 'unsafe-inline' 'unsafe-eval'

Same as no CSP. The bouncer with a sign that says "everyone welcome."

unsafe-inline + unsafe-eval together: disables CSP's XSS protection entirely. If you need inline, use a nonce.

Static (hardcoded) nonce: const NONCE = 'abc123' - the attacker reads it from the HTML and bypass is total. The nonce must be cryptographically random, per request, minimum 128 bits.

Forgetting connect-src for WebSocket: app works in dev, breaks in prod because wss:// is blocked. Add wss://your-domain explicitly; 'self' does not cover WebSocket schemes in older Safari.

Mixing nonce and allowlist: once 'strict-dynamic' is present, browsers ignore 'self', https:, and host allowlists in script-src. Teams add a CDN to the list, see it still blocked, and panic. That's by design.

Implement Reporting Before Enforcement

Don't go straight to production blocking everything. Use Content-Security-Policy-Report-Only first:

Note: report-uri is deprecated in favor of report-to + the Reporting-Endpoints header, but Chrome and Firefox still honor report-uri and Safari only honors report-uri. Send both during transition.

Report-Only lets everything work but logs violations. You discover what would break before it actually breaks.

CSP in Next.js

Debugging Cheat Sheet: Reading CSP Console Errors

When CSP blocks something, the console yells. Good news: the error tells you exactly what to fix. Bad news: only if you know how to read it.

Anatomy of a violation:

Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self' 'nonce-xyz'".
Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a
nonce ('nonce-...') is required to enable inline execution.

Read it in three parts:

  1. What was blocked -> "inline script" (could be: script, stylesheet, image, frame, connect)
  2. Which directive fired -> script-src (which rule to edit)
  3. What would unblock it -> nonce, hash, or 'unsafe-inline' (pick the safest, never the last)

Common patterns and fixes:

Console message contains...What's happeningFix
Refused to execute inline script<script> tag without nonceAdd nonce={nonce} or move to external file
Refused to apply inline stylestyle="..." attribute or <style> blockMove to CSS file, or nonce the block
Refused to connect to 'wss://...'WebSocket blockedAdd origin to connect-src
Refused to load the script 'https://...'Third-party script not allowlistedAdd to script-src or use strict-dynamic
Refused to frame 'https://...'iframe blockedAdd to frame-src
Refused to load the image 'data:...'data URI imageAdd data: to img-src

Browser gotcha: Firefox truncates the blocked-uri in reports to the origin only (no path), while Chrome keeps the full URL. If your report dashboard shows https://cdn.example.com with no path on Firefox traffic, that's why; correlate with source-file and line-number instead.

Pro tip: the reporting endpoint gives you structured JSON with blocked-uri, violated-directive, and source-file. Way better than scrolling through user bug reports. Pipe it into Sentry or a Slack channel and treat it as a live XSS alarm.

FAQ

Q: Does CSP replace sanitization and output escaping? No. CSP is defense in depth. Your framework should still escape output; CSP is the safety net for when that fails. Two locks on the door are better than one.

Q: Will CSP slow down my site? The overhead is a single HTTP header and a trivial policy check per resource. Unmeasurable in practice. Your images are slower than CSP.

Q: I use Google Tag Manager / Intercom / Hotjar. They inject inline scripts. Am I stuck with unsafe-inline? No. Use strict-dynamic with a nonce. The loader gets the nonce, and any scripts it injects inherit trust automatically. That's literally why strict-dynamic exists.

Q: My policy works in Chrome but breaks in Safari. Help? Safari lags on CSP Level 3. strict-dynamic works but has quirks, wss:// isn't covered by 'self' in older versions, and some fetch directives fall back to default-src differently. Always test in Safari and Firefox, not just Chrome. The report endpoint will catch browser-specific issues if you're watching.

Q: Should I put CSP in a <meta> tag instead of an HTTP header? You can, but don't. <meta> CSP can't use frame-ancestors, report-to/report-uri, or sandbox, and it only kicks in after the parser reaches the tag, so any script above it already ran. Use the header.

Key Takeaways

  • CSP is your last line of defense against XSS, it complements sanitization, doesn't replace it
  • strict-dynamic + nonce is state of the art. Makes allowlists unnecessary
  • Start with default-src 'none' and open up as needed
  • Never use unsafe-inline + unsafe-eval together
  • Always block object-src and restrict base-uri
  • Use Report-Only before enforcement, and send both report-to and report-uri during transition
  • The biggest cause of ineffective CSP isn't technical: it's teams that add unsafe-inline to fix things quickly and never remove it

The bouncer only works if you stop slipping your friends in through the back door.