Migrating from unsafe-inline to Hash-Based CSP
Permalink to "Migrating from unsafe-inline to Hash-Based CSP"Part of CSP Nonces & Hash-Based Policies, this page shows how to replace the blanket unsafe-inline keyword with 'sha384-…' source hashes for your inline scripts — the right approach when content is static and a per-request nonce is impossible.
Quick Reference
Permalink to "Quick Reference"| Property | Value |
|---|---|
| Token syntax | script-src 'sha384-<base64>' |
| Preferred algorithm | SHA-384 (sha256 / sha512 also valid) |
| Hashed content | Exact inner text of the inline <script>, byte-for-byte |
| Encoding | base64 of the raw digest (not hex) |
| Recompute trigger | Any change to the inline body, including whitespace |
| Rollout header | Content-Security-Policy-Report-Only first |
Cancels unsafe-inline |
Yes — a hash present disables unsafe-inline |
| Browser support | Chrome 40+, Firefox 31+, Safari 15.4+, Edge (all Chromium) |
Use hashes for static, cacheable pages; use nonces for server-rendered dynamic HTML, as covered in Generating Per-Request CSP Nonces.
Why Hashes Replace unsafe-inline Cleanly
Permalink to "Why Hashes Replace unsafe-inline Cleanly"unsafe-inline tells the browser to execute any inline script, which means an injected <script>alert(document.cookie)</script> runs with the same authority as your own analytics snippet — the policy provides no inline protection at all. A hash-source flips that default: the browser computes the SHA-384 digest of each inline block’s exact text and executes it only if that digest is listed in script-src. Because an attacker cannot make their injected payload hash to a value you pre-registered, the injection is refused while your known blocks run. Unlike a nonce, a hash needs no per-request server state, so it survives full-page caching and works on statically generated HTML — the cost is that every byte change to the inline body forces a recompute of the token.
That trade-off is what makes hashes the right migration target for the pages a nonce cannot cover. A statically generated marketing page, a document served straight from a CDN edge cache, or any response with Cache-Control: public cannot carry a fresh per-request nonce, because a shared cache would replay one visitor’s nonce to everyone. Hashes have no such constraint: the token is a property of the content, so the identical cached body validates against the identical cached policy for every visitor. The migration therefore usually proceeds route by route — dynamic, server-rendered routes adopt nonces, while static and cacheable routes adopt hashes — and both remove unsafe-inline from the same script-src because the presence of either a nonce or a hash cancels it browser-wide.
Canonical Implementation
Permalink to "Canonical Implementation"Compute the SHA-384 digest over the exact inner text of the inline script — the bytes between > and </script>, including leading and trailing whitespace — then list the token in script-src.
# Hash the exact inner text of the inline script (stored in inline.js)
openssl dgst -sha384 -binary inline.js | openssl base64 -A
# → oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/ux8JHrklT1RNnZ3d8l6+pNnS3lPmW2
Given this inline block:
<script>window.__APP__ = { ready: true };</script>
The file inline.js must contain exactly window.__APP__ = { ready: true }; with no added newline. List the resulting digest as a hash-source:
Content-Security-Policy:
script-src 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/ux8JHrklT1RNnZ3d8l6+pNnS3lPmW2';
object-src 'none';
base-uri 'none'
The browser now runs that specific inline block and refuses any other inline script. Note there is no unsafe-inline — the hash cancels it.
Variant Examples
Permalink to "Variant Examples"Build-tool hash extraction
Permalink to "Build-tool hash extraction"Let the build compute hashes for every emitted inline block so the policy never drifts from the markup. This Node script scans built HTML, digests each inline script, and prints the tokens to fold into your header:
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
const html = readFileSync('dist/index.html', 'utf8');
const tokens = [...html.matchAll(/<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/g)]
.map(([, body]) => 'sha384-' + createHash('sha384').update(body, 'utf8').digest('base64'))
.map((t) => `'${t}'`);
console.log(`script-src ${tokens.join(' ')} 'strict-dynamic'`);
Wire this into CI so a changed inline block regenerates the policy automatically; a stale hash is the most common cause of a self-inflicted outage.
strict-dynamic on top of a hashed loader
Permalink to "strict-dynamic on top of a hashed loader"When one hashed inline block is a loader that pulls in the rest of your bundle, add 'strict-dynamic' so the scripts it injects inherit trust without a host allowlist:
Content-Security-Policy:
script-src 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/ux8JHrklT1RNnZ3d8l6+pNnS3lPmW2' 'strict-dynamic';
object-src 'none'; base-uri 'none'
The hashed loader runs, and any external file it appends is trusted by propagation. Those injected external files should still carry integrity and crossorigin="anonymous" so their bytes are verified — see Configuring Content Security Policy with SRI.
Report-Only rollout
Permalink to "Report-Only rollout"Deploy the new policy under the report-only header first so violations are logged but nothing breaks:
Content-Security-Policy-Report-Only:
script-src 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/ux8JHrklT1RNnZ3d8l6+pNnS3lPmW2';
object-src 'none'; base-uri 'none';
report-to csp-endpoint
Watch the report stream across a full traffic cycle. Every legitimate inline block that shows up as a violation is one whose hash you missed — add it, redeploy, and only switch to the enforcing Content-Security-Policy header when the stream is clean.
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"-
The hash covers whitespace, so trailing newlines break it. The digest is over the exact bytes between the tags. A template engine that adds a leading newline or your editor’s trailing
\nchanges the hash. Compute the digest from the rendered output, not a source snippet, and disable any HTML minifier reflow after the hash is computed. -
Every content change forces a recompute. Unlike a nonce, a hash is bound to the byte content. Bumping a version string or a feature flag inside an inline block silently invalidates its hash and the block is refused in production. Generate hashes in the build (see the build-tool variant) so markup and policy can never diverge.
-
Hashes only cover inline content, not external
src. A'sha384-…'token inscript-srcauthorizes an inline block; it does nothing for<script src="…">. For external files, authorization comes from a host allowlist, a nonce, or'strict-dynamic', and byte integrity comes from anintegrityattribute. Do not confuse a CSP script hash with an SRI hash — they use the same algorithm but cover different things. -
Omitting
crossoriginon an SRI-tagged external script. If your hashed loader injects an external<script>with anintegrityattribute but nocrossorigin="anonymous", the browser issues a no-CORS request, gets an opaque response it cannot read, and blocks the file — a failure that looks like a hash mismatch but is really a missing CORS attribute. Always pairintegritywithcrossorigin="anonymous":<script src="https://cdn.example.com/lib.min.js" integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC" crossorigin="anonymous"></script> -
Inline event handlers are never covered by a hash.
onclick="…"and other attribute handlers cannot be authorized by a hash-source and will be refused onceunsafe-inlineis gone. Refactor them into a hashed<script>block that callsaddEventListener. -
Third-party snippets churn their inline body. Tag managers, A/B testing tools, and analytics loaders frequently update the inline bootstrap they ask you to paste, and each update silently invalidates the hash you registered. Either pin the snippet version and re-hash on every vendor bump, or wrap the vendor logic in your own hashed loader that fetches an external, SRI-verified file instead of shipping vendor code inline.
-
Algorithm mismatch produces a confusing non-match. A
sha384-token will not validate a block you accidentally digested withsha256. Keep one algorithm across your build pipeline and your policy; SHA-384 is the recommended default. Listing the same block under two algorithms is valid but doubles the maintenance surface for no security gain.
Verification Steps
Permalink to "Verification Steps"1. Confirm the computed hash matches the emitted block
Permalink to "1. Confirm the computed hash matches the emitted block"Extract the inline body from the built page and re-hash it:
# Pull the first inline script body and digest it
sed -n 's/.*<script>\(.*\)<\/script>.*/\1/p' dist/index.html | tr -d '\n' \
| openssl dgst -sha384 -binary | openssl base64 -A
The output must appear as a 'sha384-…' token in your script-src.
2. Watch the Report-Only stream
Permalink to "2. Watch the Report-Only stream"With the Content-Security-Policy-Report-Only header live, load the site and check your report-to endpoint (or the DevTools console) for entries like:
[Report Only] Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'sha384-…'".
Each such entry is a block whose hash is missing. A clean stream across real traffic is the signal to enforce.
3. Confirm enforcement blocks injection
Permalink to "3. Confirm enforcement blocks injection"After switching to the enforcing header, try injecting an unregistered inline script from the console:
document.body.appendChild(Object.assign(document.createElement('script'), { textContent: 'window.x=1' }));
Expected result — refused with a script-src 'sha384-…' violation, and window.x stays undefined, while your legitimate hashed blocks continue to run.
Related
Permalink to "Related"- CSP Nonces & Hash-Based Policies — parent workflow covering nonce-source and hash-source directives,
'strict-dynamic', and enforcement end-to-end - Generating Per-Request CSP Nonces — the nonce-based alternative for server-rendered dynamic HTML
- Configuring Content Security Policy with SRI — pairing hash-based
script-srcwithrequire-sri-forso external scripts are byte-verified too