CSP Blocked Inline Script — Nonce, Hash, and Fixes
CSP blocked your inline script and the page went quiet.
20+ years shipping production backend systems. Notes here come from systems that actually shipped.
- ✓A site where you can set response headers
- ✓Basic familiarity with HTML script tags and events
- ✓Access to browser devtools console
- A blocked inline script means your Content Security Policy has no script-src permission covering that block — the browser refused to run it
- Don't fix it with unsafe-inline: bless specific scripts with a per-request nonce or a SHA-256 hash instead
- Move behavior into external .js files served from your own origin; event handlers and javascript: URLs must go too
- Roll out in report-only mode first with report-uri monitoring, and fix every violation report before enforcing
- Keep object-src none, lock down base-uri, and review third-party scripts before adding them to your policy
Picture a theater that only lets actors on stage if their names are on tonight's list. Your inline script is an actor who always walked on unannounced — and tonight the theater enforces the list, so the show goes quiet. Content Security Policy is that door list: the browser runs only scripts from approved sources or carrying an approved stamp. The fix isn't firing the doorman (which invites every impersonator) — it's getting your actors properly listed.
You deploy on Friday, open the site, and half the page is dead. Buttons don't respond, the checkout widget never loads, analytics flatlines. The console shows a wall of red: 'Refused to execute inline script because it violates the following Content Security Policy directive.' Nothing in your JavaScript changed. What changed is that the browser started enforcing a policy — yours, newly deployed or newly tightened — that your inline scripts don't satisfy.
Content Security Policy (CSP) is a browser-enforced allowlist delivered in an HTTP header (or meta tag). It tells the browser which sources may provide scripts, styles, images, and more — and by default, inline scripts and event-handler attributes are blocked even on your own pages. That default surprises teams whose apps grew up on onclick attributes and footer script blocks, but it's the control that neuters entire classes of cross-site scripting: injected markup can't execute when nothing unapproved runs.
The fix path is well-trodden: understand why the block fired, bless legitimate scripts with nonces or hashes (never unsafe-inline), refactor inline code into external files where it belongs, and roll the policy out through report-only monitoring before enforcing. This guide walks that path with production-tested steps — and shows how to tighten the policy into a real XSS defence instead of a checkbox header.
Why Your Inline Script Stopped Running
When a CSP script-src directive is present, the browser builds a guest list for JavaScript: files from listed origins may run, everything else is refused at parse time. Inline <script> blocks aren't on the list by default — not because the browser doubts your code, but because it can't distinguish your block from an attacker's injected one. Same for onclick attributes and javascript: URLs: they execute markup-embedded code with no origin to check, so the policy treats them as guilty until proven blessed.
The console message is your diagnostic: it names the violated directive and quotes the policy, telling you exactly which rule fired. 'Refused to execute inline script because it violates script-src' means the block lacked a matching nonce or hash — the fix is blessing the script, not loosening the directive. Resist the siren call of 'unsafe-inline': it re-admits every inline script including injected ones, deleting the XSS protection you deployed the policy for. It's the one token that turns the whole header decorative.
Start every CSP fix with an inventory, not an edit. Open each affected page, collect every console violation, and map each blocked script to its owner and purpose — you'll routinely find forgotten A/B test snippets, dead analytics loaders, and CMS-injected widgets nobody claims. Removing the dead code first shrinks the blessing work and the attack surface together. What remains gets a nonce, a hash, or a new home in an external file.
Nonce vs Hash: Two Ways to Bless a Script
Nonces and hashes both prove a script is yours, but they fit opposite situations. A nonce blesses a tag regardless of contents, suiting dynamic server-rendered scripts — but it must be fresh per response, never reused. Generate at least 128 bits from a cryptographic source for every response, emit the identical value in the header and the tag, and mark nonced responses no-store so caches never replay a token. A static hardcoded nonce copied from a tutorial blesses attacker scripts as happily as yours.
A hash blesses exact contents, suiting stable snippets — but any character change breaks the match and re-blocks the script. Automate hash regeneration in your build pipeline so policy updates ship alongside code changes, and prefer external-file versions from vendors where offered — file sources survive snippet edits without policy churn.
Choose per script, not per site: nonces for dynamic, hashes for stable, external files for everything else. Never mix 'unsafe-inline' alongside them (it overrides their protection), and never accept a static hardcoded nonce from a tutorial — a predictable nonce blesses attackers' scripts too. The Python snippet below shows the correct server pattern: fresh randomness per request, header and tag in agreement, no caching of nonced responses.
Refactoring Inline Code Into External Files
External files are the endgame: code served from your origin (or an approved CDN) needs no per-script blessing, survives policy tightening, and gains caching, versioning, and review as side benefits. The refactor has three mechanical parts. First, move each inline block's body into a .js file served from an allowed origin. Second, replace inline event attributes with addEventListener bindings in that file — onclick='buy()' becomes a listener attached by ID. Third, pass server data via safe channels: data-* attributes, JSON in a non-executing script tag, or dedicated endpoints — never by interpolating values into JS strings, which reintroduces injection.
The refactor pays for itself beyond CSP. External scripts get Content-Type enforcement, subresource integrity (SRI) hashes for CDN copies, and proper code review instead of template archaeology. Third-party widgets deserve the same treatment in reverse: prefer vendor file URLs over vendor snippets, pin them with SRI, and review what each addition executes — every host you add to script-src is code running with your page's privileges.
The JavaScript snippet below shows the canonical handler migration: markup carries data, the external file carries behavior. Apply this pattern mechanically across templates — grep for on* attributes first so none hide — and each converted page permanently leaves the inline-blessing treadmill. Your future policy edits then touch origins, not individual blessings.
Report-Only Mode and Reporting Endpoints First
Content-Security-Policy-Report-Only is the rehearsal stage: browsers evaluate the policy, log violations, and block nothing. Deploy your candidate policy in this mode with a reporting endpoint (report-uri for broad compatibility, report-to with the Reporting API where supported), then watch reports for at least two full business cycles — including weekly spikes, monthly jobs, and rare flows like high-risk checkout paths that only trigger for some users. Every legitimate violation is a script you must bless or refactor before enforcing.
Treat the report stream as data to triage, not noise to ignore. Group by violated directive and blocked source: your-own inline blocks need nonces or refactoring, unknown third-party hosts need ownership review (is that marketing tag still wanted?), and data: or blob: violations often trace to specific libraries needing explicit allowances. Volume alerts on the report endpoint catch both deploy regressions and injection attempts probing your policy — a sudden burst of blocked evil-looking sources is threat intelligence, not just breakage.
Keep reports flowing after enforcement, because policies rot: new features add scripts, vendors change snippets, CMS editors paste widgets. The curl check below belongs in CI and monitoring alike — assert the enforcing header is present with the expected directives on every deploy, so a dropped header (CDN misconfiguration, framework upgrade) pages you instead of silently disarming the browser. Monitoring first, enforcing second, monitoring forever.
The Directives That Interact: object-src, base-uri, and More
script-src gets the attention, but a policy is only as strong as its neglected directives. object-src 'none' kills plugin-based execution paths (Flash-era vectors and their modern cousins) that script-src doesn't govern. base-uri 'self' prevents attackers from hijacking relative URLs — without it, injected <base> tags reroute your scripts and forms to evil hosts while your script-src looks pristine. Both belong in every policy from day one; neither breaks legitimate apps when set correctly.
Mind the fallback chains. A missing directive falls back to default-src, so set default-src 'self' (or tighter) as the net beneath everything, then carve explicit exceptions per directive. frame-ancestors controls who may embed your pages (use it instead of the legacy X-Frame-Options where possible). form-action restricts where forms submit — an XSS that can't exfiltrate via your forms is a quieter disaster. connect-src governs fetch and WebSocket targets, containing data theft when markup injection succeeds.
Review third-party additions directive by directive. Each new allowed host in script-src runs code with full page privileges; each connect-src addition is a sanctioned exfiltration channel. Require a business owner and a review date for every third-party entry, and re-validate vendor snippets with hashes on change. A policy with twenty unreviewed marketing hosts isn't defence in depth — it's a guest list nobody checks.
Rolling Out CSP Without Breaking Checkout
The safe rollout is a sequence, not a switch: report-only monitoring, violation triage, refactoring with nonces and hashes, funnel alerting, then enforcement during business hours with eyes on the metrics — and an instant rollback path tested before you need it. Each stage has an exit criterion: monitoring is done when two business cycles show only known-legitimate violations; refactoring is done when reports would be empty under enforcement; alerting is done when a simulated header drop pages within minutes.
Make the deploy mechanics boring. Serve the header from application code or instantly-purgeable config, never from slow-propagating edge rules you can't reverse. Stage the rollout by route sensitivity if you can: low-risk content pages first, checkout and payment flows last with extra monitoring. Keep a one-command rollback (previous known-good header) rehearsed and documented — the team that needs it at 2 AM shouldn't be composing it at 2 AM.
After enforcement, keep improving. Tighten directives incrementally (strict-dynamic for modern script loading, trusted types for DOM injection sinks), prune dead third-party hosts quarterly, and review violation reports weekly as both breakage radar and attack telemetry. CSP done right compounds: each quarter the policy gets shorter, the page gets faster (fewer third parties), and the XSS that lands in your tracker becomes a blocked-violation report instead of an incident.
A New CSP Header Silenced Checkout for 52 Minutes
- Never enforce a new CSP without a report-only monitoring phase — violation reports are the inventory of what you'll break.
- script-src 'self' doesn't cover inline code. Audit every inline block, handler attribute, and javascript: URL before touching the header.
- Keep security-header deploys instantly reversible: slow CDN propagation turns a 5-minute mistake into a 52-minute outage.
| File | Command / Code | Purpose |
|---|---|---|
| rg -l --glob '*.html' -e '<script[^>]*>' templates/ | tee /tmp/inline-pages.txt | Why Your Inline Script Stopped Running | |
| app | from flask import Flask, make_response, render_template_string | Nonce vs Hash |
| static | document.addEventListener('DOMContentLoaded', () => { | Refactoring Inline Code Into External Files |
| HDR=$(curl -sSI https://app.example.com/checkout | grep -i '^content-security-po... | Report-Only Mode and Reporting Endpoints First |
Key takeaways
Common mistakes to avoid
5 patternsAdding unsafe-inline to silence violations
Reusing or caching nonces across responses
Hashing a snippet and forgetting vendors edit code
Enforcing on day one without report-only data
Setting script-src but ignoring base-uri and object-src
Interview Questions on This Topic
Why does script-src 'self' still block your inline scripts?
Frequently Asked Questions
20+ years shipping production backend systems. Notes here come from systems that actually shipped.
That's XSS. Mark it forged?
6 min read · try the examples if you haven't