Content Security Policy: Nonce vs Hash Source Expressions
A modern Content Security Policy earns its keep by refusing to execute inline <script> unless it can prove the script is one you authored. Two source expressions provide that proof: a per-request nonce-<value> that the server injects into both the header and each trusted inline script, and a sha256-<digest> hash that pins the exact bytes of a known inline script. They solve the same problem — allowing a small set of legitimate inline scripts while blocking injected ones — but they suit different rendering models, and choosing wrong means either a broken page or a policy you cannot cache. This guide compares them, covers strict-dynamic, and walks the migration off unsafe-inline. It is part of the Secure HTTP Header Configuration guide within Vulnerability Patterns & Web Mitigation Strategies.
A strict CSP is a second layer behind output encoding; for the injection side of the problem it defends against, see XSS mitigation.
Both nonces and hashes exist to let you delete 'unsafe-inline' — the single directive that renders a CSP nearly worthless against injected scripts. As long as 'unsafe-inline' sits in script-src, any attacker-injected <script> executes freely and the policy protects nothing. Picking the wrong expression for your rendering model usually ends with a team quietly re-adding 'unsafe-inline' to unbreak the page.
Prerequisites
- Control over response headers (server, edge worker, or reverse proxy)
- A build step for computing hashes, if any inline scripts are static
- A template layer where a nonce can be interpolated into inline
<script>tags - A reporting endpoint to collect
report-to/report-uriviolations
Expected Outcomes
- Dynamically rendered pages carry a fresh per-request nonce on every response
- Statically generated inline scripts are allowed by precomputed hashes
strict-dynamicremoves the need for a brittle host allowlist- No
unsafe-inlineremains in the enforcedscript-src
Step 1: Generate a Per-Request Nonce and Apply It (Server-Rendered Pages)
A nonce is a cryptographically random value generated fresh for each response. The server places it in the script-src directive and stamps the same value onto every inline script it trusts. Because an injected script cannot guess the value — and cannot read the header to copy it — it is blocked while your own scripts run. This model fits any server that renders HTML per request and can therefore vary the value each time, which describes most traditional server-rendered applications and server-side-rendered single-page apps.
The diagram contrasts the two application models: a nonce injected per request versus a hash allowlist computed once at build time.
// Express — per-request nonce
import crypto from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
export function cspNonce(req: Request, res: Response, next: NextFunction) {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
`script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'`,
"object-src 'none'",
"base-uri 'none'"
].join('; '));
next();
}
In the template, stamp the same nonce onto trusted inline scripts. Note the 'unsafe-inline' above is a deliberate fallback that modern browsers ignore in the presence of a nonce and strict-dynamic, but that older browsers honor.
<script nonce="{{ nonce }}">
window.__APP_CONFIG__ = { region: "eu", featureX: true };
</script>
Three properties make a nonce sound, and violating any one silently weakens the policy. It must be cryptographically random — at least 128 bits from a CSPRNG, never a counter, timestamp, or hash of the request. It must be unique per response, so it cannot be captured on one page and replayed on another. And it must never be exposed anywhere an attacker with an injection foothold can read it before rendering, which in practice means never echoing the nonce into a data attribute or a value derived from user input. The most common real-world failure is not a weak generator but an accidental full-page cache in front of the app that freezes one nonce and serves it to thousands of users, converting a strong control into a guessable constant.
Step 2: Compute Hashes for Static Inline Scripts
When a page is pre-rendered at build time or served from an immutable cache, a per-request nonce is impossible — every cached copy would carry the same value, defeating the whole point. Instead, hash the exact bytes of each inline script and list the digests in the policy. The browser hashes each inline script it encounters and runs only those whose digest is on the list.
// build-time: compute the sha256 of each inline script body
import crypto from 'node:crypto';
// The hash covers the exact text between the <script> tags — no surrounding whitespace changes allowed.
function scriptHash(body) {
const digest = crypto.createHash('sha256').update(body, 'utf8').digest('base64');
return `'sha256-${digest}'`;
}
const inlineBoot = `window.__APP_CONFIG__={region:"eu"};`;
console.log(scriptHash(inlineBoot));
// => 'sha256-Ky2b...=' — paste this token into script-src
# Static policy emitted at build time (e.g. _headers file for an edge host)
Content-Security-Policy: >-
default-src 'self';
script-src 'sha256-Ky2bORQ2k3wImN7...' 'strict-dynamic' https:;
object-src 'none';
base-uri 'none'
Hashes carry one advantage nonces cannot match: because the digest is derived from the content and not from a per-request secret, the entire HTML response — header and body together — can be cached, mirrored to a CDN, and served identically to every visitor without weakening the policy. This is exactly why statically generated sites and edge-cached pages standardize on hashes. The mirror-image constraint is that hashes only describe content known at build time, so any inline script whose body is assembled at runtime cannot be covered by a hash and must either be externalized into a file or moved onto a nonced page. In practice that pushes teams toward a clean separation: static marketing and documentation surfaces use hashes, while authenticated, dynamically rendered application surfaces use nonces.
The tradeoff: any change to the script body — even a single character — invalidates the hash and blocks the script, so the hash list must be regenerated by the build, never edited by hand.
A subtle but important detail is that the hash covers the exact bytes between the opening and closing <script> tags, with no leading or trailing whitespace normalization. A minifier that trims a newline, a template that indents the inline script differently in one environment, or an editor that converts line endings will all produce a different digest and silently block the script. This is why hash-based policies belong to a deterministic build pipeline: the same tool that emits the HTML must emit the digest, so the two can never drift. Hashes also compose well with a small, fixed number of boot scripts but scale poorly when inline scripts are numerous or frequently edited, which is the practical signal to prefer nonces for genuinely dynamic applications.
Step 3: Add strict-dynamic and a report-uri
strict-dynamic lets a script that was itself allowed (by nonce or hash) load further scripts without each host being in an allowlist, and it instructs conforming browsers to ignore unsafe-inline and host-source expressions. Pair it with a reporting endpoint so you see violations before they become outages, and migrate off unsafe-inline by first shipping the policy in report-only mode.
The value of strict-dynamic is that it fixes the biggest weakness of host-based allowlists: a permissive entry like script-src https://cdn.example.com trusts every file on that host, including any JSONP endpoint or outdated library an attacker can abuse as a gadget. With strict-dynamic, trust flows from the specific script you nonced or hashed to whatever it loads, so you no longer maintain a sprawling hostname list. The propagation is gated: a script injected by the attacker is never nonced or hashed, so it never enters the trusted set. This is why a modern strict policy can be as short as a nonce, 'strict-dynamic', and a couple of fallbacks, yet cover a large dependency graph.
// Report-only first, then enforce, once the report endpoint is quiet
res.setHeader('Content-Security-Policy-Report-Only', [
"default-src 'self'",
`script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'`,
"object-src 'none'",
"base-uri 'none'",
"report-uri /csp-reports",
"report-to csp-endpoint"
].join('; '));
// Collector — record violations, do not block, alert on volume
app.post('/csp-reports', express.json({ type: '*/*' }), (req, res) => {
const report = req.body?.['csp-report'] ?? req.body;
logger.warn('csp_violation', {
blocked: report?.['blocked-uri'],
directive: report?.['violated-directive'],
doc: report?.['document-uri']
});
res.status(204).end();
});
Once the report stream is clean, rename the header from Content-Security-Policy-Report-Only to Content-Security-Policy and drop unsafe-inline for browsers that do not need the fallback. For the companion headers that ship alongside CSP, see configuring HSTS and X-Frame-Options for legacy apps.
The migration off unsafe-inline is where nonces and hashes usually work together rather than compete. A typical application has a handful of static boot scripts that suit hashes and a larger set of server-rendered inline scripts that suit nonces; a single script-src can list both a 'nonce-...' token and several 'sha256-...' tokens at once, and 'strict-dynamic' then extends trust from either to any scripts they load. The ordering that keeps the policy backward compatible is deliberate: list the nonce and hashes first for modern browsers, then https: as a host fallback and 'unsafe-inline' last, which modern browsers ignore whenever a nonce or hash is present but older engines still honor. As legacy browser support is dropped, the trailing fallbacks are removed, leaving a minimal strict policy.
Nonce vs hash at a glance
| Dimension | Nonce | Hash |
|---|---|---|
| Rendering model | Dynamic, per-request HTML | Static / pre-rendered / cached |
| Value derived from | Random bytes per response | Exact script content |
| Cacheable full-page response | No (value must be unique) | Yes |
| Breaks when script body changes | No | Yes, hash must be regenerated |
| Server work per request | Generate + inject nonce | None at request time |
| Build-time work | None | Compute and embed digests |
| Handles dynamically injected scripts | Yes, with strict-dynamic |
Yes, with strict-dynamic |
| Main failure mode | Reused nonce via accidental caching | Stale hash after edit |
Verification
Confirm the header, then the browser’s own enforcement:
# 1. Nonce appears and changes across requests
curl -sI https://app.example.com/ | grep -i content-security-policy
curl -sI https://app.example.com/ | grep -i content-security-policy
# the nonce-... token must differ between the two calls
# 2. Static page: the hash token is present and stable
curl -sI https://static.example.com/ | grep -io "sha256-[A-Za-z0-9+/=]*"
In the browser console, an injected inline script should log a violation such as Refused to execute inline script because it violates the following Content Security Policy directive: script-src 'nonce-...'. A trusted, correctly nonced or hashed script runs without a warning. The reporting endpoint should receive the violation payload for the blocked script.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Trusted inline script blocked despite a nonce | Nonce in the header differs from the one on the tag (two middleware layers) | Generate the nonce once per request and reuse res.locals.nonce everywhere |
| Same nonce served to many users | A full-page cache is storing the nonced response | Switch that route to hash-based CSP or exclude it from the cache |
| Hash stops matching after a deploy | Script body changed (minifier, whitespace) and the hash was not regenerated | Recompute hashes in the build; never hand-edit the digest |
| Third-party script loaded by a trusted script is blocked | strict-dynamic missing, so the host allowlist governs |
Add 'strict-dynamic' to propagate trust to descendant scripts |
| Policy works in Chrome but breaks old browsers | Legacy browser ignores nonce and needs the fallback | Keep https: 'unsafe-inline' after the nonce; modern browsers ignore it |
Common Implementation Mistakes
Frequently Asked Questions
When should I use a nonce versus a hash in script-src?
Use a per-request nonce when the server renders HTML dynamically and can inject a fresh random value into both the header and every trusted inline script on each response. Use a hash when the markup is static or pre-rendered at build time — a statically generated site or a CDN-cached page — where a per-request value cannot be injected but the exact script bytes are known ahead of time.
Can I use nonces on a statically cached page?
Not safely. A nonce must be unpredictable and unique per response, so caching a page that embeds one reuses the same value across many users and lets an attacker predict and replay it. Statically cached or pre-rendered pages should use hash-based source expressions, which are tied to the script content rather than to a per-request secret.
What does strict-dynamic add on top of a nonce or hash?
strict-dynamic propagates trust from a nonced or hashed script to the scripts it loads dynamically, removing the need to maintain a host allowlist for every downstream dependency. It also makes conforming browsers ignore host-source and unsafe-inline expressions, which keeps a strict policy compact while staying backward compatible with older browsers that fall back to the allowlist.
Related
- Secure HTTP Header Configuration — the parent guide covering the full response-header hardening set
- Configuring HSTS and X-Frame-Options for legacy apps — the companion headers that ship alongside CSP
- Cross-Site Scripting (XSS) Mitigation — the injection class CSP backstops with encoding as the first layer
- Token storage patterns — how a hardened script surface limits what a token thief can execute