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.

unsafe-inline vs hash-source vs nonce-source A four-row comparison matrix. unsafe-inline does not block injected inline script but needs no state and survives caching. A hash-source blocks injection, needs no server state and survives caching, but must be recomputed on every content edit. A nonce-source blocks injection and never needs recomputing, but requires per-request server state and cannot survive a shared cache. Property unsafe-inline hash-source nonce-source Blocks injected inline script no yes yes Needs per-request state no no yes Survives full-page caching yes yes no Breaks on a content edit no yes no

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.

Anatomy of a hash-source token The token is drawn as five adjacent segments: an opening single quote, the algorithm label sha384, a hyphen separator, the base64-encoded digest, and a closing single quote. Callouts mark the algorithm label, the digest encoding, the separator and the fact that both quotes belong to the token. Anatomy of a hash-source token algorithm label base64 of the raw digest ' sha384 - oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/ux8JHrk ' one hyphen, no space both quotes belong to the token

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.

Report-Only rollout loop Four stages run left to right: compute a sha384 digest per inline block, deploy the policy under the Report-Only header, review the violation stream, and finally enforce the policy and drop unsafe-inline. A return path runs from the review stage back to the digest stage, because each violation identifies an inline block whose hash was missed. Report-Only rollout loop for hash-source tokens Compute sha384 per inline block Deploy under Report-Only Review the violation stream Enforce, drop unsafe-inline clean a violation means a hash you missed

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 \n changes 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 in script-src authorizes 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 an integrity attribute. Do not confuse a CSP script hash with an SRI hash — they use the same algorithm but cover different things.

  • Omitting crossorigin on an SRI-tagged external script. If your hashed loader injects an external <script> with an integrity attribute but no crossorigin="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 pair integrity with crossorigin="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 once unsafe-inline is gone. Refactor them into a hashed <script> block that calls addEventListener.

  • 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 with sha256. 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.


Permalink to "Related"

Related Articles

Generating Per-Request CSP Nonces
CSP Nonces & Hash-Based Policies Runtime Policy Enforcement & T…