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:
'sha256-…'— the algorithm token (sha256,sha384, orsha512), a hyphen, then the standard base64 digest, all wrapped in single quotes. The browser supports all three;sha256is the common choice.- Hash of the exact inline bytes — the digest is computed over the text content between the
<script>and</script>tags, byte-for-byte, with no surrounding tag markup, no leading/trailing whitespace you did not include, and in the exact character encoding the page serves. The digest above isconsole.log('hello');with no extra bytes. - Inline only — a source hash authorizes inline
<script>blocks (and, withintegrity-style matching, can apply to external scripts). It does not covereval, inline event handlers (onclick="…"), orjavascript:URIs. - One hash per distinct script — every inline block with different content needs its own hash entry; list as many as you have.
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.
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.
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.
Edge Cases, Security Implications & Safe Rollback
- Whitespace and encoding change the hash. A trailing newline, reindentation by a formatter, CRLF vs LF, or a minifier pass all alter the bytes and invalidate the digest. Always compute the hash from the final rendered, minified output and re-run it whenever the build changes. This is the single most common cause of a “correct” hash still being blocked.
- Dynamic inline scripts can’t be hashed. If the script body is generated per request (interpolated CSRF tokens, user data, server-rendered state), its bytes differ every response and no fixed hash matches. Use a per-request nonce for those blocks instead; reserve hashes for static, build-time-stable scripts.
- Multiple hashes and growth. List one
'sha256-…'per distinct inline script. Pages with many small inline blocks produce a long header that can hit proxy/server header-size limits (8 KB is a common default); consolidate inline scripts or move them to hashed external files. - Hashes do not cover everything. Source hashes authorize inline
<script>blocks, not inline event handlers (onclick) orjavascript:URIs — those still require'unsafe-inline'or refactoring to addEventListener. A hash also does not grant trust to scripts that a hashed script later injects; for that, pair the hash with'strict-dynamic', which extends trust from a hashed (or nonced) root script to the scripts it loads — see CSP strict-dynamic explained.
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.
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.
Related
- Content-Security-Policy (CSP) reference — the parent header reference and Report-Only rollout.
- Generating CSP nonces per request — the alternative for dynamically rendered inline scripts.
- CSP strict-dynamic explained — extend trust from a hashed root script to the scripts it loads.