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:

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.

Decision tree for authorizing inline scripts A two-question decision tree routing an inline script to a hash, a nonce, or refactoring, and rejecting unsafe-inline outright. Inline script to allow avoid 'unsafe-inline' Byte-identical every response? yes no HTML cacheable by a CDN? yes no Hash 'sha256-…' Nonce or Hash either is safe Per-request Nonce 'nonce-…' + no-store Content that cannot be static, e.g. injected app state, needs refactoring to an external file.
Two questions — is the script static, and is the HTML cacheable — decide the mechanism.

The full comparison across the axes engineers actually weigh — security ceiling, caching, and build cost:

Comparison matrix of the three mechanisms A matrix scoring unsafe-inline, nonce, and hash across XSS protection, CDN caching, per-request cost, and build complexity. 'unsafe-inline' 'nonce-…' 'sha256-…' XSS protection none strong strong CDN cacheable yes no yes Per-request cost none CSPRNG + render none Handles dynamic body yes yes no
Nonce trades caching for dynamism; hash trades dynamism for caching; unsafe-inline trades away all protection.

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.

How the browser evaluates each inline script A flow showing the browser checking an inline script against a nonce attribute, then a hash of its body, and blocking it if neither matches. Inline <script> parsed by browser nonce attr matches header 'nonce-…'? SHA-256 of body in 'sha256-…' set? yes yes Execute neither matches → blocked, logged to Console
The browser runs an inline script if its nonce or its body hash is listed; otherwise it blocks and reports.

Edge Cases, Security Implications & Safe Rollback

Three traps account for nearly every failed migration off 'unsafe-inline':

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.