Hash-based CSP for inline scripts

This guide is part of the Content-Security-Policy (CSP) reference and covers allowing specific inline scripts by their cryptographic hash instead of 'unsafe-inline'. You compute the SHA-256 (or SHA-384/512) digest of an inline script’s exact bytes, base64-encode it, and list it in script-src as 'sha256-…'. The browser hashes each inline script it encounters and runs only the ones whose digest is in the allowlist. Unlike a nonce, a hash is fixed for a given script body, so it survives caching and static hosting — which makes it the right tool when there is no per-request render step to mint a fresh token, but also means any change to the script bytes invalidates it.

Configuration Syntax & Exact Values

The hash goes directly into script-src. It is the base64-encoded digest of the script’s content, prefixed by the algorithm:

Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc='; object-src 'none'; base-uri 'self'
<script>console.log('hello');</script>

Annotated breakdown and the rules that make the digest match:

A single byte of difference — an added newline, a changed quote, a different indentation level after a build step — produces a completely different digest and the browser blocks the script.

Inline script hashing and allowlist match The exact bytes of an inline script are hashed with SHA-256 and base64-encoded; the browser compares that digest against the sha256 entries in the CSP script-src allowlist and executes the script only on a match. Inline bytes console.log(...) SHA-256 base64 digest script-src 'sha256-...' match? execute Same bytes always hash to the same value cacheable and static-host friendly
The browser hashes the exact inline script bytes and runs the script only when the digest appears in the script-src allowlist.

Server-Side Configuration

A hash is static, so unlike a per-request nonce you compute it once (ideally at build time) and bake it into a plain header. No application render hook is required.

Computing the hash

Hash the exact bytes of the script body. Use printf, never echo, to avoid an unintended trailing newline, and pipe the raw bytes into the digest:

# OpenSSL: digest of exactly `console.log('hello');`
printf "%s" "console.log('hello');" | openssl dgst -sha256 -binary | openssl base64
# -> RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc=
// Node: hash a script string or a file's bytes
const crypto = require('crypto');
const body = "console.log('hello');"; // or fs.readFileSync('inline.js')
const digest = crypto.createHash('sha256').update(body, 'utf8').digest('base64');
console.log(`'sha256-${digest}'`);

The string you hash must be byte-identical to what ships between the tags. If your templating engine, minifier, or editor adds or strips a newline after you compute the hash, recompute it from the rendered output, not the source.

Setting the header

Nginx

add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc='; object-src 'none'; base-uri 'self'" always;

always emits the header on error responses too, so inline scripts on a 404/500 page are covered as well.

Apache

Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc='; object-src 'none'; base-uri 'self'"

Header always set guarantees attachment across all status codes; requires mod_headers. The base64 digest contains + and /; quote the whole value as shown so Apache does not split on them.

Node/Express (Helmet)

const helmet = require('helmet');

app.use(helmet.contentSecurityPolicy({
  useDefaults: false,
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: [
      "'self'",
      "'sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc='",
    ],
    objectSrc: ["'none'"],
    baseUri: ["'self'"],
  },
}));

Each hash is a separate array entry; add one per distinct inline script.

Build-time hash generation

Compute hashes during the build so the header and the shipped markup can never drift. Walk the rendered HTML, hash each inline <script> body, and emit the script-src fragment:

// build step: derive hashes from the ACTUAL rendered HTML
const crypto = require('crypto');
const fs = require('fs');

const html = fs.readFileSync('dist/index.html', 'utf8');
const hashes = [...html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)]
  .map(m => `'sha256-${crypto.createHash('sha256').update(m[1], 'utf8').digest('base64')}'`);

const scriptSrc = ["'self'", ...new Set(hashes)].join(' ');
fs.writeFileSync('dist/csp-script-src.txt', scriptSrc);

Hashing the rendered output (not the template source) is the only way to guarantee the bytes match what the browser receives after minification and template expansion.

Build-time hash derivation pipeline The template source is minified and rendered to final HTML, and only that rendered output is hashed into the script-src fragment that becomes the shipped CSP header. Template source .js / template Minify + render build step Rendered HTML final bytes Hash each script SHA-256 → base64 script-src fragment 'sha256-...' CSP header shipped to browser Hash the rendered bytes, never the template source so the header and the shipped markup cannot drift
The hash is derived from the post-minification rendered HTML, then folded back into the header, closing the loop so bytes and allowlist stay in lockstep.

Diagnostic & Verification Steps

Confirm the header ships the hash, then load the page and watch the console.

curl -sI https://example.com/ | grep -i content-security-policy

Expected output:

content-security-policy: default-src 'self'; script-src 'self' 'sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc='; object-src 'none'; base-uri 'self'

Re-derive the hash from the live HTML and compare it to the header:

curl -s https://example.com/ -o page.html
# extract the inline script body and hash it (single inline block shown)
printf "%s" "$(sed -n 's/.*<script>\(.*\)<\/script>.*/\1/p' page.html)" \
  | openssl dgst -sha256 -binary | openssl base64

The printed digest must equal the value after sha256- in the header. In the browser, open DevTools → Console. A matching script runs silently. A mismatch raises:

Refused to execute inline script because it violates the following Content Security Policy
directive: "script-src 'self' 'sha256-…'". Either the 'unsafe-inline' keyword, a hash
('sha256-<actual-digest>'), or a nonce ('nonce-…') is required to enable inline execution.

The browser prints the expected 'sha256-…' for the offending script in that message — copy it straight into script-src if it is a script you trust.

Work the checks in order: header present first, then execution, then the console’s expected digest as the shortcut back to a correct value.

Hash mismatch triage decision tree Verify the header carries the hash, then whether the script executes, and if not copy the expected digest the console prints into the script-src directive. Header carries 'sha256-...' ? curl -sI | grep -i content-security-policy Fix server config emit the header, reload No Yes Script executes silently ? DevTools Console, no refusal Match — done digest is correct Yes No Console prints expected 'sha256-...' inside the violation message Copy that digest into script-src
Triage a blocked inline script top-down: confirm the header, confirm execution, then lift the browser's expected digest straight into the allowlist.

Edge Cases, Security Implications & Safe Rollback

Choosing hash vs nonce: use a hash for inline scripts whose bytes are fixed at build time and for pages you cache or serve statically; use a nonce for dynamically rendered inline scripts and any response you generate per request. They are not mutually exclusive — a script-src can list both.

Hash versus nonce for inline scripts A comparison matrix showing how hashes and nonces differ on fixed bytes, cacheability, per-request rendering, and invalidation when the script changes. Property Hash Nonce Script bytes fixed at build required optional Cacheable / static hosting yes no Per-request dynamic script no yes Broken by any byte change yes regenerated A script-src can list both; pick per script, not per page.
Hashes suit build-stable, cacheable scripts; nonces suit per-request dynamic ones — and a single script-src may carry both at once.

Rollback (reversible): the change is header-only and non-destructive. If a legitimate inline script is blocked in production and you cannot immediately recompute the hash, restore your prior known-good script-src (temporarily reinstating 'unsafe-inline' only if unavoidable) and reload the service.

# Revert: restore the prior script-src value, then:
# nginx -t && systemctl reload nginx

Frequently Asked Questions

Should I use a hash or a nonce for inline scripts? Use a hash when the script content is fixed at build time and the page is cacheable or statically hosted — the digest stays valid across requests. Use a nonce when the inline script is generated per request or the response is dynamic. A script-src can carry both.

My hash is correct but the script is still blocked. Why? The bytes you hashed differ from what the browser received — almost always a whitespace, newline, or encoding difference introduced by templating or minification. Recompute the digest from the final rendered output, or copy the 'sha256-…' value the browser prints in the console violation message.

Can I hash external scripts too? Source hashes in script-src apply to inline blocks. External scripts are integrity-checked with the integrity attribute (Subresource Integrity) and a matching CSP require-sri-for policy, which is a separate mechanism.

Does a hash protect scripts loaded by my hashed script? No. A hash authorizes only that exact inline block. Scripts it dynamically loads need their own coverage; add 'strict-dynamic' to propagate trust from the hashed root to the scripts it injects.

Conclusion

Roll hash-based CSP out incrementally. Generate the hashes from your rendered output in the build, validate in Content-Security-Policy-Report-Only on staging so any byte-mismatch surfaces as a report rather than a broken page, then promote to enforcing mode on production. Keep the hash derivation inside the build pipeline so the allowlist can never drift from the bytes you ship.