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.
The four properties that decide which token a route should use line up cleanly against each other.
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.
The token itself is a quoted source expression, not a bare string: the surrounding single quotes are part of the grammar, the algorithm label and the digest are joined by a single hyphen, and the digest is standard base64 with padding, never hex. Those encoding requirements are identical to the ones described in Base64 Encoding Rules for SRI Hashes, so a token that a CSP parser silently discards is usually one that lost its = padding or had + and / swapped for the URL-safe alphabet.
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. That makes the rollout a loop rather than a single step: you stay in Report-Only, folding newly discovered blocks back into the digest set, until an entire traffic cycle passes with nothing reported.
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 — the approach worked through in Self-Hosting Third-Party Scripts, which turns an unstable vendor blob into an asset your own build can hash.
-
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.
Frequently Asked Questions
Permalink to "Frequently Asked Questions"What exactly does a CSP script hash cover?
The digest is computed over the exact text content between the opening and closing script tags — every byte, including whitespace and trailing newlines. It does not cover the tag’s attributes or the src of an external script. Change one character of the inline body and the hash no longer matches.
Can I mix hashes and nonces in one policy?
Yes. script-src can carry both nonce-source and hash-source tokens simultaneously. A common pattern is a nonce for the dynamic bootstrap script plus hashes for a handful of static inline blocks. Both cancel unsafe-inline, so the presence of either enforces strict inline control.
Does removing unsafe-inline from script-src also affect inline styles?
No. script-src and style-src are independent directives, and unsafe-inline in one has no bearing on the other. Inline style blocks and style attributes keep running until you migrate style-src separately, with its own sha384 tokens computed over the exact text of each style element. Style attributes cannot be hashed at all, so those need refactoring into classes.
Why did my hash stop matching after I enabled HTML minification?
The minifier rewrote the inline body after your build computed the digest, stripping whitespace, collapsing semicolons or re-quoting strings. The bytes the browser hashes are the minified ones, so the registered token no longer matches and the block is refused. Fix the ordering: minify first, then extract the inline blocks from the final artifact and hash those.
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 - Rolling Out a Script Policy in Report-Only Mode — the full staged rollout process behind the Report-Only step here, including report triage and the criteria for flipping to enforcement