Detection Engineering

XSS CSP Hardening for Blue Teams

XSS CSP hardening for blue teams — a strict nonce-based policy, CSP violation reports as a detection feed, Sigma and Suricata rules, tuning, and MITRE mapping.

A dark browser developer console showing cyan code with one line flagged in red
Threat reference

XSS CSP hardening means replacing allowlist Content-Security-Policy rules with a strict, nonce-based policy and then wiring CSP violation reports into your SIEM as a live detection signal. A strict CSP neutralizes most reflected, stored, and DOM-based cross-site scripting even when the underlying code is still vulnerable. The reports it emits are the cheapest XSS intrusion-detection feed you will ever deploy. This guide ships both halves — the policy and the detection — plus how to roll it out without breaking the site.

Cross-site scripting still lives inside OWASP’s A03:2021 — Injection category, and it remains the most common way an attacker runs JavaScript in your users’ browsers. Output encoding alone is fragile; a strict CSP plus reporting is what blue teams should deploy.

What is XSS CSP hardening?

XSS CSP hardening is the practice of configuring a Content Security Policy strict enough to neutralize cross-site scripting, then using the policy’s own violation reports as a detection signal. It does two jobs at once: the browser refuses to run unauthorized script (prevention), and it tells you every time it had to (detection). That dual role is why it belongs in every blue team’s playbook.

A successful XSS payload runs with your application’s origin and your user’s session. That means session theft, credential harvesting via fake forms, and silent actions taken as the victim. It maps to MITRE ATT&CK T1059.007 — JavaScript for execution and T1185 — Browser Session Hijacking for impact.

What are the three types of XSS?

XSS comes in three shapes, and your policy and detection have to account for all three. The table maps each to where it is visible — which is the whole argument for CSP reporting.

VariantHow it firesWhere it’s visibleCaught by
ReflectedPayload echoed back from the requestRequest parameters in web logsWeb logs + CSP report
StoredSaved once, runs for every viewerNothing at view time in request logsCSP violation report
DOM-basedClient JS writes input into a sinkNever touches server logsCSP violation report

Request logs only see the loud reflected variant. Stored and DOM-based XSS are visible only at the browser, at render time — which is exactly what CSP violation reporting instruments. If your detection is request-log only, two of the three families are invisible.

How to build a strict, nonce-based CSP

Allowlist CSPs (script-src 'self' https://cdn.example.com ...) are notoriously hard to get right and frequently bypassable. The current OWASP and Google recommendation is a strict policy built on per-response nonces, with reporting attached:

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

Three rules make or break it: every response gets a fresh, cryptographically strong, base64 nonce stamped on every legitimate script; never ship unsafe-inline or unsafe-eval; and use strict-dynamic so nonce’d scripts can load framework code. An injected script has no valid nonce, so the browser refuses to run it — even when your code emitted it.

What belongs in a strict policy beyond script-src

script-src gets all the attention and it is roughly half the policy. The rest of a strict header closes bypasses that a nonce alone does not.

DirectiveWhat it preventsRecommended value
object-srcPlugin-based script execution, a long-standing bypass route'none'
base-uriAn injected <base> tag rewriting where relative URLs resolve'none'
form-actionA hijacked form posting credentials to an attacker endpoint'self'
frame-ancestorsClickjacking; supersedes X-Frame-Options'none' or an explicit list
require-trusted-types-forDOM-based XSS at the sink'script'
default-srcEverything you did not think to name'none', then open what you need

form-action is the most consistently omitted of these and one of the more valuable. A strict script-src stops an injected script from running; it does nothing about injected markup that rewrites a login form’s action attribute to point at an attacker’s server. The user sees a normal form, submits it, and the credentials go somewhere else. form-action 'self' closes that entirely and breaks almost nothing, since most applications post to their own origin.

base-uri 'none' closes a subtler variant of the same idea: without it, injected markup can change the document’s base URL so that every relative script and resource path resolves against an attacker-controlled host.

Trusted Types: the part script-src cannot do

A strict nonce policy is very good at stopping script the server emitted. It is much weaker against DOM-based XSS, where your own legitimate, correctly-nonced JavaScript takes attacker input and writes it into a dangerous sink — innerHTML, document.write, eval, a src assignment. That script is trusted, so the browser has no reason to intervene, and this is the XSS family that survives an otherwise excellent CSP.

Trusted Types addresses the sink rather than the source. With require-trusted-types-for 'script' set, the browser makes those DOM sinks throw unless what is assigned to them is a Trusted Type object produced by a policy you explicitly defined. Instead of auditing every path by which data might reach innerHTML, you get a hard runtime guarantee that nothing reaches it un-sanitised, and a violation report when something tries.

The practical picture:

  • It converts DOM XSS from a code-review problem into an enforced invariant, which is a categorical improvement over hoping a reviewer notices the next innerHTML.
  • Browser support is uneven. It works in Chromium-based browsers; support elsewhere has lagged. That makes it a strong defence-in-depth layer covering a large share of your users rather than a universal control, and it is a reason to keep output encoding rather than a reason to skip Trusted Types.
  • Adoption is genuinely disruptive on an existing codebase. Every sink assignment needs to go through a policy. Report-only mode is essential here, and the migration is measured in sprints, not an afternoon.
  • It pairs with DOMPurify cleanly — a policy that runs input through DOMPurify and returns a Trusted Type gives you one enforced sanitisation path for the whole application.

If you are choosing where to spend effort after a strict script-src is live, this is the highest -value next step, because it is the only one that addresses the XSS variant your server-side controls structurally cannot see.

How to detect XSS with CSP violation reports

The policy and the sensor are the same mechanism. With report-to/report-uri set, the browser POSTs a JSON report every time it blocks a script. Forwarded to your SIEM, that becomes a real-time XSS feed — including the stored and DOM variants your logs never see.

SPL Likely XSS from CSP Violation Reports
index=csp sourcetype=csp:report
| spath "csp-report.blocked-uri" output=blocked
| spath "csp-report.violated-directive" output=directive
| where directive="script-src" AND blocked!="inline"
| stats count by page, blocked, src_ip
| where count >= 3

For the loud reflected variant, a request-log Sigma rule adds early warning at the edge — the same web-tier approach used for SQL injection detection.

Sigma Web Request Containing Reflected XSS Tokens
title: Web Request Containing Reflected XSS Tokens
id: 9a1c7e44-darkpwn-illustrative
status: experimental
logsource:
  category: webserver
detection:
  selection:
    cs-uri-query|contains:
      - '<script'
      - 'onerror='
      - 'onload='
      - 'javascript:'
      - 'document.cookie'
  condition: selection
falsepositives:
  - WYSIWYG editors and scanners that legitimately pass markup
level: medium

What a violation report actually contains — and what it does not

Two report formats exist, they carry different field names, and a SIEM parser written for one silently drops the other.

report-uri is the older mechanism, still the most widely supported. The browser POSTs application/csp-report containing a single csp-report object with hyphenated keys: document-uri, violated-directive, effective-directive, blocked-uri, source-file, line-number, script-sample.

report-to uses the Reporting API. The browser POSTs application/reports+json containing an array of reports, each with type, age, url, and a body whose keys are camelCase — blockedURL, documentURL, effectiveDirective. Same information, different shape, different content type, and delivery is batched rather than immediate.

Specify both, and make sure your ingest handles both. A parser expecting csp-report.blocked-uri against a Reporting API payload finds nothing, produces no errors, and yields an endpoint that appears healthy while dropping every report from browsers using the newer path.

Now the limits, which determine how much a report is actually worth:

  • blocked-uri is reduced to the origin for cross-origin violations. You learn that something from https://evil.example was blocked, not which path. This is a deliberate privacy measure and it means reports identify the attacker’s host, not their payload.
  • Inline violations report blocked-uri as "inline", with no URL at all — and inline is exactly what most injected XSS is.
  • script-sample is only populated for some violation types and is truncated, typically to a few dozen characters. It is a hint for triage, not evidence.
  • Reports are unauthenticated and attacker-forgeable. Anyone can POST to your report endpoint. Rate-limit it, size-limit it, never render its contents unescaped in a dashboard, and treat the volume as untrusted input — it is an internet-facing endpoint that accepts JSON, and it needs the same handling as any other.

The practical reading: CSP reports are excellent as a signal that injection occurred, and on which page — which is more than request logs give you for stored and DOM XSS. They are poor as a source of payload detail. Triage from the report, then investigate the page.

How to roll out CSP without breaking the site

The fastest way to kill a CSP project is to enforce a broken policy on day one. Stage it:

  1. Report-Only first. Deploy Content-Security-Policy-Report-Only so violations are logged but nothing is blocked. Your site keeps working.
  2. Triage the reports. Separate legitimate first-party scripts (fix the policy/nonce) from third-party noise (filter it).
  3. Drive violations to near-zero, then switch to the enforcing Content-Security-Policy header.
  4. Keep the report endpoint live after enforcing. It is now your XSS IDS, not just a rollout tool.

Do not leave the policy in Report-Only forever — reports without enforcement give you detection but zero blocking.

Tighten a live policy without a second rollout

The two headers are independent, which means you can serve both at once: keep your current enforcing Content-Security-Policy protecting users, and simultaneously ship the stricter candidate as Content-Security-Policy-Report-Only. The browser enforces the first and reports against the second.

That gives you a permanent, zero-risk tightening loop. Every future change — adding Trusted Types, removing a legacy allowlisted host, dropping a directive you no longer need — is validated against real traffic before it can break anything. It is the same staged pattern as the initial rollout, except you never have to argue for a project to get it.

Route-scoped policies are worth knowing about too. A legacy admin page full of inline handlers can keep a looser policy while everything modern runs strict, rather than the entire application being held at the weakest page’s standard. The risk is that “temporary” exceptions become permanent, so attach an owner and a date to each one.

The failure modes that silently break a strict CSP

A policy can look correct in the header and provide much less than you think.

  • A cached nonce is not a nonce. If a CDN or reverse proxy caches HTML containing the nonce and serves it to many users, the value is no longer per-response, and an attacker who reads it once can use it. This is the most serious silent failure in nonce-based CSP: the header still looks perfect. Either mark nonce-bearing HTML uncacheable, or generate the nonce at the edge.
  • A predictable nonce. It must come from a cryptographically secure random source. A counter, a timestamp, or a request-ID hash defeats the entire mechanism.
  • strict-dynamic and a compromised dependency. Trust propagates to whatever your nonced scripts load, so a supply-chain compromise inherits it. Watching for new blocked-uri hosts after a deploy is the detection for exactly this.
  • A stale allowlist entry. A host you allowlisted years ago, whose domain has since lapsed or been taken over, is an open door with your explicit permission on it.
  • Reports that go nowhere. The endpoint 404s, the parser breaks after a format change, or the index fills. Nothing alerts, because the absence of reports is indistinguishable from an absence of attacks. Monitor for the report feed stopping.

That last one generalises past CSP and is worth stating in the abstract: a detection whose failure mode looks exactly like good news needs its own liveness check. A quiet CSP endpoint is either a clean application or a broken sensor, and only one of those is worth celebrating.

What CSP does not stop

An honest accounting, because a control’s limits determine what else you still need:

  • Non-script injection. Dangling markup that exfiltrates page content through an unclosed attribute, or CSS-based data extraction, needs no script execution at all.
  • The vulnerability itself. CSP stops exploitation, not the bug. The injection point is still there, still reachable, and still fixable — and a future policy weakening re-exposes it.
  • Anything after a successful supply-chain compromise of a script you legitimately trust.
  • Server-side consequences. If the XSS was reflected from stored data, that data is still poisoned for any consumer that is not a CSP-enforcing browser — API clients and server-side renderers among them.

None of this argues against a strict CSP. It argues for keeping context-aware output encoding as the primary control, with CSP as the layer that makes the inevitable missed encoding survivable.

How to defend against XSS beyond CSP

CSP is the safety net; the bug class still needs closing at the source.

  • Set HttpOnly and SameSite on session cookies to blunt the payoff — HttpOnly keeps document.cookie from leaking the session.
  • Validate input at the boundary as defense in depth, not the only control.

Common CSP mistakes

  • Leaving unsafe-inline in to “make it work.” It re-opens the exact hole the policy closes.
  • Stuck in Report-Only forever. Detection without blocking is half a control.
  • Muting the report feed because of extension noise — filter, don’t mute.
  • Hash-based policies on changing scripts. A one-byte edit breaks the hash; prefer nonces for dynamic apps.
  • Caching a page that carries a nonce. The value stops being per-response, and the policy is broken while the header still reads as strict.
  • Generating the nonce from anything but a CSRNG. A counter or timestamp is guessable, which is the same as having no nonce.
  • Omitting form-action. A strict script-src does nothing about injected markup that repoints a login form at an attacker’s endpoint.
  • Parsing only csp-report. Reporting API payloads use camelCase keys inside a body array; a parser written for one format drops the other without erroring.
  • Treating script-sample as evidence. It is truncated, absent for many violation types, and attacker-influenced.
  • Leaving the report endpoint unauthenticated and unlimited. Anyone can POST to it. Rate-limit it, size-limit it, and never render its contents unescaped.
  • Not alerting when reports stop. A silent feed looks identical to a clean application, and only one of those is good news.
  • Stopping at script-src. DOM XSS runs through your own trusted code; Trusted Types is the layer that addresses it.

XSS CSP hardening checklist

A copy-paste rollout list for your blue team:

  1. Replace any allowlist CSP with script-src 'nonce-{RANDOM}' 'strict-dynamic'.
  2. Remove unsafe-inline and unsafe-eval entirely.
  3. Generate a fresh, cryptographically strong nonce per response; stamp it on every legit script.
  4. Add report-uri/report-to and pipe violation reports to the SIEM.
  5. Deploy in Content-Security-Policy-Report-Only, triage violations, then enforce.
  6. Filter extension/analytics noise (chrome-extension://, known hosts) from the report feed.
  7. Encode output by context; gate dangerouslySetInnerHTML, v-html, and raw innerHTML in review.
  8. Sanitize user-supplied HTML with DOMPurify — never a regex denylist.
  9. Set HttpOnly and SameSite on session cookies.
  10. Alert on new blocked-uri hosts appearing right after a deploy.
  11. Add form-action 'self' and frame-ancestors, and confirm nonce-bearing HTML is not cached anywhere in your delivery path.
  12. Parse both report formats, rate-limit the endpoint, and alert when the feed goes silent.
  13. Adopt require-trusted-types-for 'script' in report-only, then enforce — it is the only control that reaches DOM-based XSS running inside your own trusted code.

Items 11 and 13 are where most mature CSP deployments still have room. A cached nonce quietly nullifies an otherwise textbook policy, and DOM XSS runs entirely inside code your policy already trusts — neither shows up as a weakness when you read the header.

The takeaway

You cannot guarantee the absence of an XSS bug, so you deploy a control that refuses to run injected script and reports every attempt. The strict policy is the shield; the violation reports are the sensor. Ship both — and continue the web-application defense arc with file upload security and SSRF detection, or the wider Detection Engineering pillar.

Training & tools referenced

Disclosure: Some links below are affiliate links. If you buy through them, darkpwn may earn a commission at no extra cost to you. We only recommend training and tools we actually use in our own lab, and affiliate links never influence editorial coverage.

  • TryHackMeAuthorized labs to practice XSS detection and CSP hardeningSecurity Training
    Start training

Frequently asked questions

Does a Content Security Policy stop all XSS?

No, but a strict nonce-based CSP neutralizes most reflected, stored, and DOM-based XSS even when the underlying code is vulnerable. It is defense in depth on top of context-aware output encoding, not a replacement for it.

What is the difference between a nonce and a hash in CSP?

A nonce is a fresh random value generated per response and stamped on every legitimate script tag. A hash pins the exact contents of an inline script. Nonces suit dynamic apps; hashes break if the script changes by even one byte, including whitespace.

Should CSP run in report-only or enforce mode?

Start in Content-Security-Policy-Report-Only to collect violations without breaking the site, fix the legitimate ones, then switch to the enforcing header. Do not leave it in report-only forever — reports without enforcement give you detection but zero blocking.

Why is strict-dynamic recommended over an allowlist CSP?

Allowlist policies are hard to get right and bypassable through hosted endpoints or open redirects on allowlisted domains. A strict-dynamic policy trusts scripts by nonce and lets them load further scripts, which is what makes modern frameworks work under a strict policy.