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.

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

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.