CSP blocks your own scripts after a framework upgrade: fix guide
For developers who upgraded a web framework and suddenly see their own JavaScript blocked by Content Security Policy. This runbook shows how to identify whether the breakage is from nonces, hashes, stricter defaults, duplicated headers, or stale edge config, and how to fix each one with concrete checks and config snippets.
TL;DR — After a framework upgrade, your app often starts emitting different script tags, inline bootstraps, or CSP headers than before. The most common fix is to stop relying on old
script-srcrules and update CSP to match the new rendering path: wire nonce generation end-to-end, or move inline bootstraps to external files and remove conflicting duplicate headers. Reading time: ~6 min
The scenario
You ship what looked like a routine framework upgrade on a Tuesday afternoon. The deploy is green, HTML is returning 200, CSS loads, and health checks stay happy — but the app is dead in the browser because your own runtime bundle never executes. DevTools starts filling with CSP violations for scripts that clearly come from your domain, and now you need to work out whether the framework changed nonce handling, injected new inline bootstrap code, or started sending a second CSP header through your proxy.
Symptoms
- Browser console shows one or more of these messages verbatim:
Refused to load the script 'https://app.example.com/_assets/app-7f3c2a.js' because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-abc123'". Note that 'strict-dynamic' is present, so host-based allowlisting is ignored.
Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'". Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required to enable inline execution.
Refused to execute inline script because its hash, its nonce, or 'unsafe-inline' does not appear in the Content Security Policy.
Loading failed for the <script> with source "https://app.example.com/_next/static/chunks/main-....js".
- Network tab shows HTML
200 OK, but one or more JS files are blocked with(blocked:csp)or never executed. - Response headers now include a stricter CSP than before, often with
script-src,script-src-elem,strict-dynamic, orrequire-trusted-types-for 'script'. - Server/proxy logs show no 4xx/5xx for the script URL, because the browser blocked it client-side.
- Report-only endpoint starts receiving violations after the upgrade:
{"csp-report":{"document-uri":"https://app.example.com/","violated-directive":"script-src-elem","blocked-uri":"inline","original-policy":"default-src 'self'; script-src 'self' 'nonce-...'; object-src 'none'"}}
Likely causes
| Cause | How common | Quick check |
|---|---|---|
| Framework now injects inline bootstrap/runtime scripts, but your CSP only allows external scripts | Very common | `curl -s https://app.example.com/ |
Nonce is generated but not attached to all framework-generated <script> tags after the upgrade | Very common | `curl -sD - https://app.example.com/ -o /tmp/page.html && grep -i '^content-security-policy:' -n /tmp/page.html /dev/stdin && grep -o 'nonce="[^"]*"' /tmp/page.html |
| Duplicate/conflicting CSP headers from app + nginx/CDN; the stricter one wins effectively | Common | `curl -sI https://app.example.com/ |
strict-dynamic was added, so 'self'/host allowlists no longer behave the way your old policy assumed | Common | `curl -sI https://app.example.com/ |
| Edge cache/CDN is serving stale HTML or stale headers after the app changed nonce/hash behavior | Sometimes | `curl -sI https://app.example.com/ |
| Hash-based CSP no longer matches minified inline script content produced by the new build | Less common | `curl -s https://app.example.com/ |
Step-by-step diagnosis
- Check whether the browser is blocking inline code or external files.
curl -s https://app.example.com/ -o /tmp/page.html
grep -n "<script" /tmp/page.html | sed -n '1,20p'
This is your problem if you see small inline <script> blocks before your main bundle and your console error says blocked-uri "inline" or Refused to execute inline script. Jump to Fixes → Framework now injects inline bootstrap/runtime scripts.
- Inspect the actual CSP headers reaching the browser.
curl -sI https://app.example.com/ | grep -i content-security-policy -n
This is your problem if you see two Content-Security-Policy headers, or one from the app and one from nginx/CDN with different script-src values. Typical output shape:
12:content-security-policy: default-src 'self'; script-src 'self' 'nonce-Jm9...'
13:content-security-policy: default-src 'self'; script-src 'self'
Jump to Fixes → Duplicate/conflicting CSP headers.
- Check whether the HTML contains nonces on all relevant script tags.
curl -sD /tmp/headers.txt https://app.example.com/ -o /tmp/page.html
grep -i '^content-security-policy:' /tmp/headers.txt
grep -n '<script' /tmp/page.html | sed -n '1,20p'
grep -o 'nonce="[^"]*"' /tmp/page.html | sort -u
This is your problem if the header contains 'nonce-...' but some framework-generated <script> tags have no nonce="...", or the nonce value in HTML does not match the one in the header. Jump to Fixes → Nonce is generated but not attached to all scripts.
- Check for
strict-dynamicchanging script trust behavior.
curl -sI https://app.example.com/ | grep -i content-security-policy
This is your problem if the policy includes 'strict-dynamic' and your old design relied on script-src 'self' https://cdn.example.com. With strict-dynamic, nonce/hash-bearing scripts become the trust root and host allowlists are ignored by supporting browsers. Jump to Fixes → strict-dynamic changed script loading behavior.
- Rule out stale edge headers or HTML.
curl -sI https://app.example.com/ | grep -Ei 'age:|x-cache|cf-cache-status|cache-control|etag|last-modified'
This is your problem if HTML is cached unexpectedly, Age keeps climbing, or cache status stays HIT after you changed CSP/nonces. Example:
cache-control: public, max-age=600
age: 487
x-cache: HIT
Jump to Fixes → Stale edge cache/CDN.
- If you use hash-based CSP for inline scripts, recompute the hash from the exact rendered inline content.
python3 - <<'PY'
from bs4 import BeautifulSoup
import base64,hashlib
html=open('/tmp/page.html','rb').read()
soup=BeautifulSoup(html,'html.parser')
for s in soup.find_all('script'):
if not s.get('src') and s.string:
h=base64.b64encode(hashlib.sha256(s.string.encode()).digest()).decode()
print("sha256-"+h)
PY
This is your problem if the computed hash is not present in script-src. Jump to Fixes → Hash-based CSP no longer matches built output.
Fixes
Framework now injects inline bootstrap/runtime scripts, but your CSP only allows external scripts
Preferred fix: stop fighting the framework and either nonce those inline scripts or move them out of inline execution if the framework allows it.
If you control the template/server, generate a per-response nonce and send it in both the header and script tags:
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-$request_id'; object-src 'none'; base-uri 'self'" always;
sub_filter_once off;
sub_filter '<script ' '<script nonce="$request_id" ';
That nginx sub_filter approach is a last resort; it is brittle and can miss scripts or break HTML. Better: set nonce in the app render layer. Example in Node/Express:
import crypto from "node:crypto";
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString("base64");
res.setHeader("Content-Security-Policy", `default-src 'self'; script-src 'self' 'nonce-${res.locals.cspNonce}'; object-src 'none'; base-uri 'self'`);
next();
});
Then render:
<script nonce="{{cspNonce}}">window.__BOOTSTRAP__ = {...}</script>
<script nonce="{{cspNonce}}" src="/assets/app.js"></script>
If the framework can externalize runtime/bootstrap code, use that instead of adding 'unsafe-inline'.
Verify it worked:
curl -s https://app.example.com/ | grep -n '<script' | head
You should see nonce="..." on inline and external scripts, and no new CSP errors in DevTools.
Nonce is generated but not attached to all framework-generated <script> tags after the upgrade
Wire the nonce through the framework-specific document/layout entry point rather than only your hand-written template. The exact file varies by framework, but the pattern is always: generate nonce per request, pass it into the HTML renderer, attach it to every script tag the framework emits.
If you terminate at nginx and app both touch CSP, remove nonce generation from one layer and keep a single source of truth.
For server-rendered apps behind nginx, pass the nonce upstream:
proxy_set_header X-CSP-Nonce $request_id;
Then in the app:
const nonce = req.get("x-csp-nonce") || crypto.randomBytes(16).toString("base64");
res.setHeader("Content-Security-Policy", `script-src 'self' 'nonce-${nonce}'`);
Do not reuse one static nonce across requests; that defeats the point.
Verify it worked:
curl -sD - https://app.example.com/ -o /tmp/page.html && grep -i '^content-security-policy:' -n /tmp/headers.txt && grep -o 'nonce="[^"]*"' /tmp/page.html | sort -u
You should see one nonce value, and it should match the header.
Duplicate/conflicting CSP headers from app + nginx/CDN
Remove CSP from every layer except one. If nginx is authoritative, hide upstream CSP:
proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-$request_id'; object-src 'none'; base-uri 'self'" always;
If the app is authoritative, remove nginx CSP:
# delete or comment any add_header Content-Security-Policy ... lines
Reload nginx:
nginx -t && sudo systemctl reload nginx
Verify it worked:
curl -sI https://app.example.com/ | grep -i content-security-policy -n
You should get exactly one CSP header.
strict-dynamic changed script loading behavior
If you keep strict-dynamic, you must nonce or hash the bootstrap script that loads the rest. Host allowlists like 'self' and CDN origins are not sufficient in supporting browsers.
Use a policy like:
Content-Security-Policy: default-src 'self'; script-src 'nonce-RANDOM' 'strict-dynamic'; object-src 'none'; base-uri 'self';
Then ensure the initial trusted script has that nonce:
<script nonce="RANDOM" src="/assets/runtime.js"></script>
If you are not intentionally using strict-dynamic, remove it and keep explicit sources:
Content-Security-Policy: default-src 'self'; script-src 'self' https://static.examplecdn.com 'nonce-RANDOM'; object-src 'none'; base-uri 'self';
Trade-off: removing strict-dynamic may restore old behavior faster, but it weakens the policy if your threat model depended on nonce-based trust chaining.
Verify it worked:
curl -sI https://app.example.com/ | grep -i content-security-policy
Then reload the page and confirm the external bundle executes.
Stale edge cache/CDN is serving stale HTML or stale headers
Purge cached HTML and stop caching nonce-bearing responses.
Set HTML to no-store or private, while allowing long cache on fingerprinted JS assets:
location / {
add_header Cache-Control "no-store" always;
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
In your provider dashboard, purge the affected URL or purge all HTML routes. Vendor UIs vary; in your CDN dashboard this is usually under caching/purge.
Verify it worked:
curl -sI https://app.example.com/ | grep -Ei 'cache-control|age:|x-cache|cf-cache-status'
HTML should no longer show long-lived cache headers or increasing Age.
Hash-based CSP no longer matches minified inline script content produced by the new build
Recompute the SHA-256 hash from the exact rendered inline script bytes and update script-src.
Example:
INLINE='window.__BOOTSTRAP__={"buildId":"abc123"};'
printf '%s' "$INLINE" | openssl dgst -sha256 -binary | openssl base64 -A
Then add the result:
Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-BASE64_HASH_HERE'; object-src 'none'; base-uri 'self';
Edge case: even harmless whitespace or JSON key order changes will invalidate the hash. For frequently changing bootstraps, nonces are usually lower-maintenance than hashes.
Verify it worked:
curl -sI https://app.example.com/ | grep -i content-security-policy
The new sha256-... should appear, and the inline script should execute.
Prevention
- Add a CI smoke test that fails on duplicate CSP headers:
test "$(curl -sI https://preview.example.com | grep -ic '^content-security-policy:')" -eq 1
- Add a browser-based check in CI for CSP violations on first paint. With Playwright:
page.on('console', msg => {
if (msg.text().includes('Content Security Policy')) process.exit(1);
});
await page.goto(process.env.APP_URL);
- Pin HTML caching rules separately from asset caching so nonce-bearing documents are never cached:
location = / { add_header Cache-Control "no-store" always; }
location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable" always; }
- Keep CSP in one layer only and codify it. Example nginx test in deploy scripts:
nginx -T | grep -n "Content-Security-Policy"
- Run Report-Only first on framework upgrades, collect violations for a day, then enforce:
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'nonce-RANDOM'; report-uri /csp-report
- Add an integration test that asserts every rendered
<script>has a nonce when CSP uses nonces:
html=$(curl -s https://preview.example.com)
nonce_count=$(printf '%s' "$html" | grep -o '<script[^>]*nonce=' | wc -l)
script_count=$(printf '%s' "$html" | grep -o '<script' | wc -l)
test "$nonce_count" -eq "$script_count"
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI