Cross-site scripting is old enough that most developers can recite the definition, yet it still shows up in nearly every web assessment. The reason is structural: HTML is a text format, and any time untrusted text is concatenated into it, the parser cannot tell data from markup. This post walks through the mechanics of one reflected XSS, fixes it the right way, and then adds a Content-Security-Policy (CSP) as the second layer that catches the bugs that slip through. Everything here is built against a toy application running on your own machine.

How the injection happens

Here is a search endpoint that many frameworks make easy to write:

// vulnerable: the query parameter is pasted straight into HTML
app.get("/search", (req, res) => {
  const q = req.query.q ?? "";
  res.send(`<h1>Results for ${q}</h1><p>Nothing found.</p>`);
});

Request /search?q=shoes and the page says "Results for shoes". Request /search?q=<img src=x onerror=alert(document.domain)> and the browser receives an img element with an event handler, which it dutifully runs. That is a reflected XSS: the payload travels in the request and comes straight back. A stored XSS differs only in timing; the same string is saved (a comment, a profile name, a support ticket) and rendered later for every reader.

Reflected XSS: untrusted input becomes markup Attacker crafts a link, waits Victim browser logged in, trusts the site Vulnerable server echoes q into HTML 1. link with payload /search?q=<script>…</script> 2. GET /search?q=… 3. 200 OK, q pasted raw into the page 4. parser finds a script tag, runs it 5. session token leaves Same response, now with Content-Security-Policy: script-src 'nonce-R4nd0m' 'strict-dynamic'; report-to csp <script nonce="R4nd0m" src="/app.js"> allowed: nonce matches the header <script>steal(document.cookie)</script> blocked: no nonce, never executes Browser POSTs a violation report to the reporting endpoint: document-uri, violated-directive, blocked-uri, script-sample
Top: the five steps of a reflected XSS. Bottom: the same page, served with a nonce-based policy. The injected script never runs and the browser files a report.

What the attacker gets depends on the page. In the victim's origin the script can read the DOM, call the application's own APIs with the victim's cookies, register a service worker, or quietly exfiltrate form input. HttpOnly cookies stop document.cookie from being read, but they do not stop the script from making authenticated requests from inside the page.

Encoding is the fix, CSP is the seatbelt

The root cause is the missing boundary between data and markup, so the fix is to encode output for the context it lands in. Inside an HTML text node that means escaping five characters; inside an attribute it means quoting plus the same escapes; inside a URL it means percent-encoding; inside a <script> block there is no safe escaping at all and the data belongs in a data-* attribute or a JSON <script type="application/json"> island instead.

const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);

app.get("/search", (req, res) => {
  const q = escapeHtml(req.query.q ?? "");
  res.send(`<h1>Results for ${q}</h1><p>Nothing found.</p>`);
});

In practice you let a templating engine with auto-escaping (Jinja2, Nunjucks, Go templates, React's JSX) do this for you and treat every safe, raw or dangerouslySetInnerHTML as a code-review event.

CSP does not fix the bug. It changes what the browser is willing to execute, so that when the next bug ships, the injected script is refused. The two layers are complementary: encoding stops the injection, CSP limits the blast radius when encoding is forgotten.

Writing the policy

A CSP is a list of directives, each naming the sources a type of content may come from. The ones that matter for XSS are few:

Directive What it controls Recommended value
default-src Fallback for every fetch directive 'self'
script-src JavaScript (external, inline, eval) 'nonce-…' 'strict-dynamic'
style-src Stylesheets and inline styles 'self' (nonces if you need inline)
img-src Images 'self' data: or your CDN
connect-src fetch, XHR, WebSocket, beacons your API origins
object-src Flash-era plugins, <object>, <embed> 'none'
base-uri <base href> rewriting relative URLs 'none'
frame-ancestors Who may embed this page (replaces X-Frame-Options) 'none' or your own origin
form-action Where forms may submit 'self'
report-to / report-uri Where violation reports go a logging endpoint you control
upgrade-insecure-requests Rewrites http: subresources to https: present
require-trusted-types-for Forces DOM sinks through Trusted Types 'script' once the app is ready

Nonces, hashes and why allow-lists lost

The first generation of CSPs listed allowed script hosts: script-src 'self' cdn.example.com. This fails in two ways. Any host that serves user-controlled JavaScript (a CDN with a JSONP endpoint, an old AngularJS build) becomes a bypass, and the list rots as the application changes. The current recommendation is a nonce-based policy:

  • The server generates a fresh random value per response, say 128 bits base64-encoded.
  • It puts 'nonce-<value>' in script-src and nonce="<value>" on every legitimate <script> tag.
  • 'strict-dynamic' lets those trusted scripts load further scripts programmatically (document.createElement("script")), while ignoring any host allow-list and refusing parser-inserted scripts such as the injected one.

A hash-based policy ('sha256-…' of the exact inline script body) does the same job for static pages where no server runs per request. Both approaches make an injected <script> fail for the same reason: the attacker cannot know this response's nonce, and cannot make their payload hash to an allowed value.

Headers in practice

  • The nonce is generated per request, stored on res.locals for the template, and emitted in the header. Switching from report-only to enforcing is one environment variable.

    import crypto from "node:crypto";
    
    app.use((req, res, next) => {
      const nonce = crypto.randomBytes(16).toString("base64");
      res.locals.nonce = nonce;
    
      const policy = [
        "default-src 'self'",
        `script-src 'nonce-${nonce}' 'strict-dynamic'`,
        "style-src 'self'",
        "img-src 'self' data:",
        "connect-src 'self' https://api.example.com",
        "object-src 'none'",
        "base-uri 'none'",
        "frame-ancestors 'none'",
        "form-action 'self'",
        "report-to csp-endpoint",
      ].join("; ");
    
      const header = process.env.CSP_ENFORCE === "1" ? "Content-Security-Policy" : "Content-Security-Policy-Report-Only";
      res.setHeader(header, policy);
      res.setHeader("Reporting-Endpoints", 'csp-endpoint="https://example.com/csp-report"');
      next();
    });
    

    In the template every script tag carries the nonce:

    <script nonce="{{ nonce }}" src="/static/app.js"></script>
    <script nonce="{{ nonce }}">
      window.__APP_CONFIG__ = JSON.parse(document.getElementById("config").textContent);
    </script>
    

    If you prefer a maintained middleware, Helmet exposes the same directives as a configuration object and accepts a function for the nonce.

  • nginx cannot run application code, but $request_id is a 32-hex-character random value generated per request, which is good enough as a nonce. The application emits a placeholder and sub_filter replaces it on the way out.

    location / {
        proxy_pass http://app_upstream;
    
        # Per-request nonce from nginx's own random request id
        add_header Content-Security-Policy-Report-Only
            "default-src 'self'; script-src 'nonce-$request_id' 'strict-dynamic'; style-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; report-to csp-endpoint" always;
        add_header Reporting-Endpoints 'csp-endpoint="https://example.com/csp-report"' always;
    
        # The app writes nonce="__CSP_NONCE__" on its script tags
        sub_filter_once off;
        sub_filter_types text/html;
        sub_filter 'nonce="__CSP_NONCE__"' 'nonce="$request_id"';
        proxy_set_header Accept-Encoding "";   # sub_filter needs uncompressed upstream bodies
    }
    

    The always parameter matters: without it nginx only adds headers to 2xx and 3xx responses, and error pages are exactly where reflected input tends to appear. See the ngx_http_headers_module documentation.

Rolling it out without breaking anything

The hard part of CSP is not writing the header; it is discovering the inline handlers, third-party widgets and eval-based template engines that a decade of development has accumulated. The Content-Security-Policy-Report-Only header exists for this: the browser evaluates the policy, blocks nothing, and sends a report for every violation it would have enforced.

Rolling out CSP without breaking the site 1. Inventory where does scriptcome from? • list every scriptsrc and host • inline handlers(onclick=) • eval, new Function,setTimeout(str) • third-party widgets,analytics 2. Report-Only nothing isblocked yet • Content-Security-Policy-Report-Onlyheader • report-to / report-uriendpoint • ship to a slice oftraffic first 3. Triage fix what thereports show • move inline JSinto files • add nonces to the rest • replace eval withsafe APIs • ignore extension noise 4. Enforce rename theheader • Content-Security-Policy • keep the reportendpoint • watch error budgets,roll back per routeif needed 5. Tighten remove theescape hatches • drop host allow-listsfor 'strict-dynamic' • object-src 'none',base-uri 'none' • require-trusted-types-for 'script' What the reporting endpoint typically sees many few time (report-only phase, then enforcement) switch to enforcing here first-party inline scripts, legacy handlers residual: browser extensions, scanners,real injection attempts worth alerting on
A report-only phase turns CSP adoption from a guessing game into a backlog. Report volume drops as first-party violations are fixed; what remains is noise from browser extensions and the occasional real attack.
  1. Inventory. Grep the codebase and templates for <script> without src, on*= attributes, javascript: URLs, eval, new Function, and setTimeout with a string argument.
  2. Deploy report-only to a small percentage of traffic with a reporting endpoint that deduplicates by (document-uri, violated-directive, blocked-uri).
  3. Triage. Move inline scripts into files or give them nonces, replace handlers with addEventListener, and swap eval-using libraries for versions that do not need it. Reports whose blocked-uri is chrome-extension:// or moz-extension:// can be dropped.
  4. Enforce by renaming the header, route by route if you can. Keep reporting on; violations are now an alerting signal.
  5. Tighten. Remove any remaining host allow-lists in favour of 'strict-dynamic', add require-trusted-types-for 'script' once DOM sinks are wrapped, and consider Cross-Origin-Opener-Policy and friends for the next class of bugs.
What a violation report looks like

With the Reporting API (report-to), the browser batches reports as JSON. Each one carries enough context to find the offending template, including a sample of the blocked script when the policy includes 'report-sample'.

[
  {
    "age": 12,
    "type": "csp-violation",
    "url": "https://example.com/search?q=%3Cscript%3E...",
    "user_agent": "Mozilla/5.0 ...",
    "body": {
      "documentURL": "https://example.com/search?q=%3Cscript%3E...",
      "effectiveDirective": "script-src-elem",
      "disposition": "report",
      "blockedURL": "inline",
      "originalPolicy": "default-src 'self'; script-src 'nonce-9Xq...' 'strict-dynamic'; ...",
      "statusCode": 200,
      "sample": "steal(document.cookie)"
    }
  }
]

A blockedURL of inline with disposition: "enforce" on a page that should have no inline scripts is as close to a free intrusion-detection signal as the web platform offers.

Checking your work

Paste the final header into the CSP Evaluator. It flags 'unsafe-inline', missing object-src, missing base-uri, and allow-listed hosts known to host JSONP endpoints. In the browser, the DevTools console prints every violation with the directive it hit, which is the fastest way to debug a page during triage. Finally, try your own injection in the toy app: with encoding fixed the payload renders as text, and with CSP enforced an un-nonced script is refused even if you deliberately remove the escaping.

WARNING

'unsafe-inline' in script-src turns the policy off for the exact case CSP exists to stop. Browsers ignore it when a nonce or hash is present, which is a reasonable fallback for very old clients, but if it is the only source expression you have no XSS protection. The same goes for 'unsafe-eval', which permits eval, new Function and string-argument timers, and for a bare * or https: in script-src.

Further reading