CSP: unsafe-inline vs Nonce vs Hash
This guide is part of the Content-Security-Policy (CSP) reference and settles a single recurring decision: how to authorize the inline scripts your application still ships without opening the door to injection. script-src gives you three mechanisms — 'unsafe-inline', a per-request 'nonce-…', and a content 'sha256-…' hash. They are not interchangeable. One is a security nullifier that must never appear in a script-src, one requires uncacheable HTML, and one only fits content that never changes. Pick by asking two questions: is the inline script static across responses, and can the enclosing HTML be cached?
Configuration Syntax & Exact Values
The three source expressions live inside script-src and each authorizes inline <script> execution by a different rule:
Content-Security-Policy: script-src 'self' 'unsafe-inline'
Content-Security-Policy: script-src 'self' 'nonce-r4nd0mBase64PerRequest'
Content-Security-Policy: script-src 'self' 'sha256-B2yPHKaXnvFWtRChIbabYmUBFZdVfKKXHbWtWidDVF8='
Annotated breakdown of what each keyword actually does:
'unsafe-inline'— authorizes every inline<script>, everyon*event-handler attribute, and everyjavascript:URI on the page. It draws no distinction between the script you wrote and one an attacker injected, so it defeats the entire purpose of CSP for XSS. Never place it in ascript-src. Its only legitimate use isstyle-srcon projects that cannot yet migrate inline styles.'nonce-…'— the literal prefixnonce-plus a base64 token, single-quoted. The browser executes an inline<script nonce="…">only when its attribute matches the header value byte-for-byte. The token must be cryptographically random and freshly minted per HTTP response; see generating per-request CSP nonces.'sha256-…'— a base64 SHA-256 (orsha384/sha512) digest of the exact bytes between the<script>and</script>tags, single-quoted. The browser hashes each inline script’s body and executes it only if the digest is listed. The header is identical for every visitor, so the HTML stays fully cacheable; see hash-based CSP for inline scripts.
The decision follows directly from those properties. Static, cache-friendly HTML wants a hash; server-rendered HTML that already varies per request wants a nonce; nothing wants 'unsafe-inline' for scripts.
The full comparison across the axes engineers actually weigh — security ceiling, caching, and build cost:
Server-Side Configuration
Hashes are static strings you can bake into a static header directive. Nonces must be generated per request in application code and cannot be set from a static server config alone. The blocks below show each platform’s exact directive.
Nginx
# Static hash — same header for every response, HTML stays cacheable.
# 'always' emits the header on error responses (4xx/5xx) too, which is
# where an injected error page would otherwise dodge the policy.
add_header Content-Security-Policy "script-src 'self' 'sha256-B2yPHKaXnvFWtRChIbabYmUBFZdVfKKXHbWtWidDVF8='; object-src 'none'; base-uri 'self'" always;
Nginx cannot mint a random nonce per request in stock config; generate it upstream (app or njs) and pass it through. The Nginx security headers configuration covers add_header inheritance rules that silently drop this header inside nested location blocks.
Apache
# 'always' (the onsuccess table would skip non-2xx responses) guarantees
# the CSP is present on every status code, matching Nginx's flag.
Header always set Content-Security-Policy "script-src 'self' 'sha256-B2yPHKaXnvFWtRChIbabYmUBFZdVfKKXHbWtWidDVF8='; object-src 'none'; base-uri 'self'"
Full inheritance and .htaccess precedence rules are in the Apache hardening guide.
Express + Helmet
// Nonce path: generate per request, then reference it in the directive.
// Helmet emits the header on every response including errors.
const crypto = require('crypto');
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
next();
});
app.use(helmet.contentSecurityPolicy({
directives: {
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
objectSrc: ["'none'"],
baseUri: ["'self'"],
},
}));
See the Node/Express Helmet configuration for ordering Helmet ahead of your view layer so res.locals.cspNonce is available in templates.
Django
# django-csp >= 4: hash for static inline, nonce for dynamic. The
# middleware sets the header on every response by default.
CONTENT_SECURITY_POLICY = {
"DIRECTIVES": {
"script-src": [
"'self'",
"'sha256-B2yPHKaXnvFWtRChIbabYmUBFZdVfKKXHbWtWidDVF8='",
],
"object-src": ["'none'"],
"base-uri": ["'self'"],
},
}
# For nonces, add SELF and 'nonce' handling via django-csp's {{ request.csp_nonce }}.
The Django security middleware guide shows wiring request.csp_nonce into templates.
Cloudflare / Next.js
// Next.js middleware: nonce per request via the Edge runtime.
export function middleware(request) {
const nonce = btoa(crypto.randomUUID());
const res = NextResponse.next();
res.headers.set('Content-Security-Policy',
`script-src 'self' 'nonce-${nonce}'; object-src 'none'; base-uri 'self'`);
res.headers.set('x-nonce', nonce);
return res;
}
For static hash headers pushed at the edge, the Cloudflare headers guide and Vercel/Next.js header management cover the _headers and next.config.js paths.
Diagnostic & Verification Steps
Confirm which mechanism is live and that it behaves correctly. First, read the header:
curl -sI https://example.com/ | grep -i content-security-policy
content-security-policy: script-src 'self' 'sha256-B2yPHKaXnvFWtRChIbabYmUBFZdVfKKXHbWtWidDVF8='; object-src 'none'; base-uri 'self'
For a nonce deployment, fetch twice and confirm the token changes. A repeated token means a cache is serving stale HTML and the nonce is worthless:
for i in 1 2; do curl -sI https://example.com/ | grep -io "nonce-[A-Za-z0-9+/=]*"; done
nonce-Zk9xQb2mР1v8Wc4dLpTq==
nonce-7HtN0aYrE6sJfUiC3bVoAg==
Compute the hash for a static inline script so you can compare it against the header — the digest is over the bytes between the tags, with no surrounding whitespace changes:
printf '%s' 'console.log("boot");' | openssl dgst -sha256 -binary | openssl base64
kx8V0nQmF3yR7cJ2aLpTq0wZ9bXeUiH5dNvBg4sCoI=
In Chrome DevTools, a blocked script logs to the Console with the exact refusal reason. A hash mismatch reads: Refused to execute inline script because it violates the following Content Security Policy directive: "script-src …". Either the 'unsafe-inline' keyword, a hash ('sha256-…'), or a nonce ('nonce-…') is required. The message even prints the computed hash of the blocked script, so you can copy it straight into script-src.
Edge Cases, Security Implications & Safe Rollback
Three traps account for nearly every failed migration off 'unsafe-inline':
- The backward-compatibility interaction. When a
script-srccontains a nonce or a hash, CSP Level 2+ browsers ignore'unsafe-inline'. This is intentional: you can shipscript-src 'unsafe-inline' 'nonce-…'so that ancient browsers fall back to'unsafe-inline'while modern browsers enforce the nonce. Adding'strict-dynamic'extends this — it makes the browser also ignore'self'and host allowlists, trusting only scripts loaded by an already-trusted (nonced/hashed) script. See CSP strict-dynamic explained. The failure mode is assuming'unsafe-inline'still applies to modern browsers when a nonce is present — it does not. - Whitespace and encoding breaks hashes. The hash covers the literal bytes between the tags. A build step that reindents, minifies, appends a trailing newline, or rewrites
"to"changes the digest and blocks the script. Hashes suit content emitted by a pipeline that also computes the digest from the same final bytes, not hand-edited templates. - Cached nonces are silent no-ops. A per-request nonce on HTML that a CDN or reverse proxy caches will be replayed to thousands of users, or mismatched after regeneration. Any nonced document must carry
Cache-Control: no-store. If your caching requirement is non-negotiable, that is precisely the signal to switch that page to a hash.
Rollback is non-destructive because CSP is evaluated fresh on every response — there is no preload-style lock-in. To back out, ship the header under Content-Security-Policy-Report-Only instead of the enforcing name; violations are reported but nothing is blocked. Reverting the add_header/Header set line entirely restores prior behaviour on the next request with no cached state to purge.
Conclusion
Roll the decision out incrementally. Begin in staging with Content-Security-Policy-Report-Only so every inline script that would break surfaces as a report rather than a blank page. For static, cacheable routes bake in 'sha256-…' digests computed by the build; for server-rendered routes that already vary per request, mint a per-request 'nonce-…' and mark those documents no-store. Promote from report-only to enforcing on a low-traffic route first, keep a short observation window, then extend the enforcing header across production — and never let 'unsafe-inline' reach a script-src.
Frequently Asked Questions
Is 'unsafe-inline' ever acceptable in script-src?
No. In script-src it authorizes every inline script including injected ones, which nullifies CSP’s XSS protection. Its only defensible use is style-src on projects mid-migration, and even there a nonce or hash is preferable. If you cannot use a nonce or hash, refactor the script to an external same-origin file allowed by 'self'.
Can I combine a nonce and a hash in the same policy?
Yes. script-src 'self' 'nonce-abc' 'sha256-…' is valid; the browser runs an inline script if it matches either the nonce or a listed hash. This is common when most inline blocks are static (hashed) but a few carry per-request data (nonced). Note that once any nonce or hash is present, 'unsafe-inline' is ignored by CSP Level 2+ browsers.
Why does my hash-authorized script still get blocked?
The digest is over the exact bytes between <script> and </script>. A build step that changed indentation, added a trailing newline, minified, or altered encoding changed those bytes and therefore the hash. Recompute from the final served bytes — the DevTools Console prints the browser’s computed hash, which you can paste directly into script-src.
Do nonces and hashes apply to external scripts?
A hash in script-src can authorize an external script if you use Subresource Integrity semantics, but the inline hash form matches only inline <script> bodies. A nonce authorizes both inline scripts and <script nonce="…" src="…"> external references. Neither applies to on* event-handler attributes, which require refactoring to addEventListener.
How does 'strict-dynamic' change the choice?
'strict-dynamic' makes browsers trust scripts loaded programmatically by an already-trusted (nonced or hashed) script, and ignore 'self' and host allowlists. It layers on top of nonces or hashes rather than replacing them, letting you drop fragile host allowlists once your entry-point script is authorized. Use it after a nonce or hash deployment is stable.