Cross-site scripting becomes dangerous when attacker-controlled text reaches a browser in a form that the browser can execute. Context-aware output encoding and safe DOM APIs remain primary defenses because they stop data from becoming executable markup. A Content Security Policy can add another boundary: even if an injection flaw creates a script element, the browser can refuse to execute it unless the page explicitly grants that script permission.

A nonce-based policy is a practical way to express that permission for server-rendered pages. The server creates an unpredictable value for one HTTP response, places it in the Content-Security-Policy header, and attaches the same value to script elements that the application intends to execute. A script inserted through an injection flaw does not possess the value and is blocked.

This control is strongest when the nonce is fresh, unpredictable, attached only to intended scripts, and combined with application code that keeps untrusted data out of trusted script bodies.

A nonce is a per-response execution capability

Consider this response:

Content-Security-Policy: script-src 'nonce-r4Nd0mExampleValue' 'strict-dynamic'; object-src 'none'; base-uri 'none'

The HTML contains a matching attribute:

<script nonce="r4Nd0mExampleValue" src="/assets/app.js"></script>

The browser compares the nonce source in the policy with the nonce attribute on the script element. A match grants that element permission to execute.

An injected element without the nonce is not granted that permission:

<script src="https://attacker.invalid/payload.js"></script>

The important boundary is not whether a script is inline or external. It is whether the browser has evidence that the application authorized that script for this response.

Treat the nonce as a short-lived capability. It should exist for one rendered response and should not become a reusable site secret.

Generate a fresh unpredictable value for every response

A nonce must not be a counter, timestamp, user identifier, route name, or other predictable value. If an attacker can predict the next nonce, an injection flaw can include that value and satisfy the policy.

Generate the value with a cryptographically secure random source. At least 128 bits of random data is a sensible baseline. Encode those bytes in a form suitable for an HTTP header and HTML attribute, such as Base64.

A simplified Node.js example:

import crypto from "node:crypto";

function createNonce() {
  return crypto.randomBytes(16).toString("base64");
}

Create it once for the response, then pass the same value to both the response-header builder and the HTML renderer.

Do not cache a fully rendered page containing a nonce and serve that same representation to many visitors. That turns a per-response value into a reusable value. If a CDN or reverse proxy caches HTML, design the cache path so each delivered representation receives a fresh nonce, or use a CSP design compatible with the caching architecture.

Keep the policy and markup tied to one rendering context

A useful server-side pattern is to make the nonce part of the request or rendering context:

app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString("base64");

  res.locals.cspNonce = nonce;
  res.setHeader(
    "Content-Security-Policy",
    `script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'`
  );

  next();
});

A template can then consume the value:

<script nonce="{{ cspNonce }}" src="/assets/runtime.js"></script>
<script nonce="{{ cspNonce }}" src="/assets/app.js"></script>

The application should control placement of the nonce attribute. Do not expose a template feature that lets arbitrary user content set attributes on trusted script elements.

Framework integrations often provide a request-scoped value or response-header hook. The architectural requirement stays the same: one unpredictable value is created for the response, and only application-approved script elements receive it.

A nonce does not make a trusted script body safe

This pattern is unsafe:

<script nonce="{{ cspNonce }}">
  const message = "{{ userControlledText }}";
  renderMessage(message);
</script>

The script element is authorized, so the browser executes its entire contents. If the template inserts attacker-controlled text into JavaScript syntax without correct context-specific encoding, the nonce does not protect that boundary.

Prefer moving data into a non-executable channel and reading it as data. For example, serialize data into a format and location with a carefully defined parser, or fetch it from an endpoint after the trusted application code starts. When data must be embedded in a script context, use a serializer designed for that exact context rather than string concatenation.

The policy decides which script elements may execute. It does not inspect trusted JavaScript and separate safe statements from injected statements.

Avoid copying nonces onto arbitrary scripts

A common implementation mistake is middleware that scans generated HTML and adds the current nonce to every <script> element. That approach can authorize an attacker-created script element if the injection occurs before the rewriting step.

Authorization must come from trusted application structure, not merely from the presence of a script tag.

Template components, server-side rendering helpers, and framework primitives should add the nonce only where application code has intentionally declared an executable script. User-authored HTML, rich-text content, comments, profile fields, and similar data must not gain a nonce through generic post-processing.

This distinction is central: the nonce marks trust; it must not be assigned based on syntax alone.

Use strict-dynamic with a deliberate browser-support plan

The 'strict-dynamic' source expression changes how a nonce- or hash-authorized script can establish trust for scripts it loads dynamically. In supporting browsers, trust can propagate from an authorized root script to scripts that root loads through script APIs.

That model can fit modern applications that start from a small set of trusted entry points and then load chunks or modules. It can also reduce dependence on broad hostname allowlists.

A policy might begin with:

Content-Security-Policy: script-src 'nonce-RESPONSE_VALUE' 'strict-dynamic'; object-src 'none'; base-uri 'none'

Browser compatibility and application requirements may call for additional fallback sources. Test the effective behavior across the browser versions your service supports. Do not add broad sources casually; a permissive fallback can weaken protection in clients that do not apply the newer directive behavior.

Host allowlists also have structural limits. A trusted host may serve user-controlled content, JSONP-like endpoints, redirects, or other resources that can become script gadgets. A nonce-centered trust model reduces reliance on the security properties of every path on every listed host.

Remove unsafe-inline rather than canceling the control

A script policy containing 'unsafe-inline' can permit inline script execution in cases where the intended nonce boundary would otherwise block it. During migration, teams sometimes add it to restore functionality after discovering inline event handlers or legacy script blocks.

A stronger migration is to inventory those execution points and convert them.

Replace inline event handlers:

<button onclick="submitOrder()">Submit</button>

with markup that contains no executable attribute:

<button id="submit-order">Submit</button>

and attach behavior from an authorized script:

document
  .getElementById("submit-order")
  .addEventListener("click", submitOrder);

This separates document structure from executable code and makes the policy easier to reason about.

Do not treat CSP as a switch that must move from absent to perfect in one deployment. Use reporting, inventory current behavior, remove incompatible patterns, then tighten enforcement in controlled stages.

Constrain other browser features alongside scripts

A script policy addresses one execution path. A compact policy can also close adjacent paths that frequently create trouble.

object-src 'none' disables plugin-style embedded objects that most modern applications do not need. base-uri 'none' prevents injected <base> elements from changing how relative URLs resolve. frame-ancestors can separately control which origins may embed the page.

Keep each directive tied to an explicit application requirement. Avoid copying a large policy from another service without checking what each source grants.

CSP also does not replace server-side authorization, CSRF defenses, secure cookie settings, output encoding, input handling, or dependency maintenance. It is one browser-enforced boundary in a layered design.

Roll out with reports before enforcing broadly

A strict policy can break legitimate application behavior if existing pages depend on inline handlers, inline scripts without nonces, unexpected third-party scripts, or dynamic loading patterns.

A staged rollout gives teams evidence before blocking production traffic. A report-only policy asks browsers to report violations without enforcing them:

Content-Security-Policy-Report-Only: script-src 'nonce-RESPONSE_VALUE' 'strict-dynamic'; object-src 'none'; base-uri 'none'

Reporting endpoints require their own operational care. Reports can be noisy, can contain attacker-influenced values, and should not be treated as trusted telemetry. Apply rate controls, normalize fields, and avoid placing sensitive data in diagnostic output.

After legitimate violations are addressed, move the tested policy to the enforcing Content-Security-Policy header. Keep monitoring after enforcement because new application features can introduce incompatible execution paths.

Test the security property, not just the header text

A unit test that checks for the header is useful but incomplete. Test the relationship among nonce generation, markup, and browser behavior.

Useful checks include:

  • two separately rendered responses receive different nonce values;
  • the policy nonce matches the nonce on each intended executable script;
  • user-controlled markup cannot obtain the current nonce through rendering helpers;
  • injected scripts without a valid nonce are blocked in browser integration tests;
  • trusted scripts still start under the enforced policy;
  • cached HTML does not cause nonce reuse across responses;
  • report collection does not expose secrets or create an unbounded ingestion path.

Also test failure modes. If nonce generation fails, avoid silently falling back to a constant or removing the policy. A secure failure path should be explicit and observable.

Keep the trust boundary small

Nonce-based CSP is easiest to maintain when a page has a small number of trusted entry scripts. Those scripts can initialize the application, attach event handlers, and load approved modules. The HTML then contains mostly structure and data rather than scattered executable fragments.

That shape improves reviewability. A reviewer can inspect the places that receive a nonce and ask whether each one is truly an application-controlled execution point.

The central design rule is simple: generate a fresh unpredictable nonce for each response, publish it in the script policy, and attach it only to script elements that application code explicitly trusts. Keep untrusted data out of those trusted script bodies. With those constraints, an injection flaw has a harder path from markup creation to JavaScript execution.