Lập trình · 02/10/2026

Practical Content Security Policy: Nonces, Hashes, and strict-dynamic

Content Security Policy (CSP) does not fix an XSS vulnerability, but it can make an injection point much harder to turn into code execution. Instead of trusting every script from an allowlisted domain, a strict CSP authorizes individual scripts with a nonce or hash, then uses 'strict-dynamic' to propagate trust to dynamically loaded dependencies.

Content Security Policy thực chiến: Nonce, hash và strict-dynamic

Content Security Policy (CSP) does not fix an XSS vulnerability, but it can make an injection point much harder to turn into code execution. Instead of trusting every script from an allowlisted domain, a strict CSP authorizes individual scripts with a nonce or hash, then uses 'strict-dynamic' to propagate trust to dynamically loaded dependencies.

This guide builds a practical policy for server-rendered applications, explains when hashes are a better fit, and provides a rollout plan that avoids breaking production.

1. CSP's place in XSS defense

A browser receives CSP through the Content-Security-Policy response header and uses it to decide which resources, scripts, and inline behaviors may run. CSP is defense in depth. Applications must still contextually encode output, sanitize untrusted HTML, avoid dangerous sinks, and repair the root cause of every XSS issue.

A policy such as script-src 'self' https://cdn.example trusts an entire origin. A JSONP endpoint, upload area, or user-controlled response on an allowed host may create a bypass. A strict CSP changes the trust unit from a domain to a specific script approved by the server.

A good CSP is a seat belt, not permission to skip encoding and sanitization.

2. Nonces, hashes, and strict-dynamic

MechanismBest fitPrimary control
'nonce-…'Dynamically server-rendered HTMLA fresh random value per response, attached only to trusted scripts
'sha256-…'Stable inline scripts and static or long-cached HTMLThe digest must match the script bytes exactly
'strict-dynamic'Trusted roots that dynamically load dependenciesAudit every value passed to script-creation APIs

The W3C Content Security Policy Level 3 specification requires a nonce to be unpredictable and freshly generated whenever a policy is transmitted, and recommends at least 128 bits before encoding. Trusted script elements in one response can share that nonce, but the next response needs a new value.

In a CSP3-capable browser, 'strict-dynamic' causes host sources, scheme sources, and 'self' in script-src to be ignored while nonce and hash sources remain effective. An authorized script can create another script through DOM APIs. Parser-inserted scripts, including those created with document.write(), do not automatically inherit this trust.

3. A baseline policy for server-rendered applications

Content-Security-Policy:
  default-src 'none';
  script-src 'nonce-{RANDOM}' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self';
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';
  form-action 'self';

This is a starting point, not a copy-and-paste answer. External APIs, WebSockets, image CDNs, payment frames, and third-party fonts need deliberate directives. Starting from default-src 'none' makes unclassified resources fail closed instead of inheriting a broad default.

  • object-src 'none' removes legacy object/embed plugins from the attack surface.
  • base-uri 'none' prevents an injected <base> element from changing relative URL resolution.
  • frame-ancestors 'none' controls who may embed the page and must be declared explicitly.
  • form-action 'self' limits form destinations if markup is compromised.

The web.dev strict CSP guide likewise uses a nonce or hash, 'strict-dynamic', object-src 'none', and base-uri as the core structure. Extend it from observed application flows rather than adding wildcards.

4. Generate a nonce correctly in PHP

<?php
$nonce = base64_encode(random_bytes(16));

$policy = implode('; ', [
    "default-src 'none'",
    "script-src 'nonce-{$nonce}' 'strict-dynamic'",
    "style-src 'self'",
    "img-src 'self' data:",
    "connect-src 'self'",
    "object-src 'none'",
    "base-uri 'none'",
    "frame-ancestors 'none'",
    "form-action 'self'",
]);

header('Content-Security-Policy: '.$policy);
?>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>"
        src="/assets/app.js"></script>

random_bytes(16) provides 128 cryptographic random bits; Base64 turns them into a nonce-source-compatible value. Seeing the nonce in HTML is expected. Its security property is that an attacker cannot predict it before the response is built and cannot reuse an old value.

Never derive the nonce from a query string, cookie, session ID, or timestamp. Do not generate one at process startup and reuse it. If a template engine injects nonce attributes automatically, ensure it touches only application-controlled scripts. Adding the nonce to every script in untrusted HTML approves the attacker's script too.

5. Nonces and caching

HTML with a per-response nonce cannot be cached as an immutable file without a deliberate strategy. If a CDN stores both header and body, it can replay a nonce. If a layer rewrites only the header, the policy and HTML attributes diverge and the application blocks its own scripts.

  • Do not shared-cache nonce-bearing HTML, or generate a fresh synchronized nonce in both header and body at the edge.
  • Cache versioned JavaScript assets normally; the nonce lives on the loading element, not inside the asset.
  • Test cache hits and misses, multiple routes, error responses, and authenticated pages.
  • Refresh repeatedly and verify that the nonce changes while all trusted scripts in one response match its header.

Hash-based CSP is often a better match for static HTML served unchanged by a CDN.

6. Hash stable inline scripts

printf '%s' 'window.appBoot()' \
  | openssl dgst -sha256 -binary \
  | openssl base64

Content-Security-Policy: script-src 'sha256-{BASE64_DIGEST}' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script>window.appBoot()</script>

A hash authenticates content, so whitespace, a newline, or different minification can change the digest. Generate hashes in the build pipeline from the final artifact instead of copying them from pre-build source. Multiple inline blocks need multiple hashes, or the logic should move into a bundle.

Do not confuse a CSP hash with Subresource Integrity. CSP decides which scripts the page permits; SRI verifies that a resource fetched from a URL has the expected digest.

7. Trust propagation can propagate mistakes

// app.js was authorized with a nonce.
const script = document.createElement('script');
script.src = '/assets/chunk-dashboard.js';
document.head.appendChild(script);

With 'strict-dynamic', this chunk can run without a host entry in the policy. That works well with modern bundlers. The same behavior becomes dangerous when untrusted data controls the URL:

// Dangerous: URL input selects executable code.
const next = new URL(location.href).searchParams.get('module');
const script = document.createElement('script');
script.src = next;
document.head.appendChild(script);

Use a fixed mapping from business identifiers to known URLs. Audit createElement('script'), appendChild, dynamic imports, and third-party loaders. CSP does not sanitize an attacker-controlled script location.

8. Roll out with Report-Only

Before enforcement, send the same policy under Content-Security-Policy-Report-Only. Browsers report violations without blocking resources. Use DevTools, automated tests, and a reporting endpoint to build an inventory:

Content-Security-Policy-Report-Only:
  default-src 'none';
  script-src 'nonce-{RANDOM}' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';
  report-to csp-endpoint;

Report-Only provides observation, not protection. Treat client reports as untrusted input: cap body size, rate-limit, validate the schema, and avoid injecting payloads directly into logs or dashboards. Reported URLs may carry sensitive data, so define retention and redaction rules.

Never automatically allow every host seen in a report. Browser extensions, local malware, attack probes, and forgotten dependencies all add noise. Every exception needs an owner, business reason, and review date.

9. Migration plan for a live application

  1. Inventory: find inline scripts, handlers such as onclick, javascript: URLs, eval, dynamic loaders, and third parties.
  2. Move code: replace inline handlers with addEventListener, bundle stable scripts, and remove unused dependencies.
  3. Choose the base: nonce for server-rendered HTML, hash for static HTML, or a justified combination.
  4. Run Report-Only: cover critical routes, supported browsers, authenticated states, checkout, and registration.
  5. Enforce in slices: start on low-risk routes or a traffic percentage; monitor JavaScript errors and business conversion.
  6. Tighten: remove excess hosts, wildcards, 'unsafe-inline', and 'unsafe-eval' rather than preserving a temporary policy forever.

Older-browser support may require https: or 'unsafe-inline' fallbacks. Modern browsers that understand nonce/hash and 'strict-dynamic' ignore the applicable fallbacks, while older browsers receive a weaker policy. Add them only when audience data proves the need and document a removal date.

10. Pre-enforcement test checklist

  • The final response after proxies and CDN has the intended CSP, not an accidental merge from multiple layers.
  • Every response has a fresh high-entropy nonce, and header and trusted script attributes match.
  • Scripts without a nonce, inline handlers, and controlled XSS probes are blocked.
  • Bundles, lazy chunks, workers, APIs, fonts, images, and authentication flows still work.
  • Cache hits and misses, errors, redirects, streaming, and hydration are covered.
  • The reporting endpoint withstands noisy traffic and alerts on violation rates.
  • A fast rollback exists without permanently widening the policy.

The objective is not a long header; it is a small, explainable, testable trust boundary. A fresh per-response nonce with 'strict-dynamic' is usually maintainable for dynamic HTML, while hashes preserve caching for static pages. In either case, observable rollout and behavioral tests turn CSP from decorative configuration into a useful security layer.

Discussion

Comments 0

Sign in to comment

You need an account to join the discussion and reply to other readers.

Sign inRegister

No comments yet. Be the first to share your thoughts.