0
Web Security INTERMEDIATE 15 min read Updated

Cross-Site Scripting (XSS): Reflected, Stored and DOM Explained

A hands-on cross-site scripting (XSS) guide: how reflected, stored and DOM-based flaws work, the output context that picks the payload, what the bug really costs, and how to close it.

founder & security researcher · 39 writeups
Cross-Site Scripting (XSS): Reflected, Stored and DOM Explained

Cross-site scripting (XSS) is the bug that lets your input run as code inside someone else's browser. The application takes a value you control, a search term, a comment, a URL fragment, and writes it into a page without keeping it as plain text, so the browser parses your value as markup or script and runs it in the victim's session. This guide walks the three types you will meet (reflected, stored and DOM-based), shows how the output context decides the payload, proves execution in a lab, covers what the bug actually buys an attacker, and finishes on detection and the fix.

Read it top to bottom once to build the model, then keep it open as a reference. Every payload here is a harmless proof such as a benign alert, and every example runs against a lab you own or are authorized to test.

Scope

Run these techniques only against an application you own or have written permission to assess, such as the lab host lab.0xpriv.dev. Firing payloads at a live site you do not control is a crime, even a harmless alert box.

01What cross-site scripting (XSS) is and why it happens

Cross-site scripting (XSS) happens when data you control reaches an output sink in a page without being treated as text. A web page is just HTML text until the browser parses it into a document and runs any script it finds. When your value lands in a spot where the parser expects markup or code, the browser cannot tell your data from the developer's code, so it executes it. The whole bug is a confusion between data and code, the same root cause behind SQL injection, only the interpreter here is the browser instead of the database.

Two words make the rest of the guide click: source and sink. A source is where attacker-controlled data enters (a query parameter, a form field, a header, the URL fragment). A sink is where that data is written into the page (an HTML echo, an attribute, a line of JavaScript). XSS is a source reaching a dangerous sink without the right encoding in between.

source to sink: how the payload executes
Source?q= user input reaches Sinkecho into page no encoding Browser parsestreats it as code run

Here is the minimum vulnerable code. A search term is echoed straight into the HTML body, so a value with tags in it becomes part of the document rather than text on the page.

php
// lab.0xpriv.dev/search?q=... reflected into the HTML body, no encoding
echo "<h1>Results for " . $_GET['q'] . "</h1>";  // the sink

Pass q=hello and you get a heading. Pass q=<script>...</script> and the browser sees a real script tag, because nothing turned your < into &lt; on the way out. That single missing step is the entire vulnerability.

02Reflected, stored and DOM-based: the three types

The three types of XSS differ by where the payload lives and when it runs, not by what the payload does once it fires. Knowing which one you have tells you how to deliver it and where to look for the fix.

Reflected XSS bounces the payload straight back in the response to the request that carried it. The value is in the URL or the POST body, the server echoes it into the page, and it executes the moment someone loads that crafted request. There is nothing stored, so delivery means getting a victim to follow your link.

Stored XSS (also called persistent) saves the payload on the server, in a database comment, a profile field, a support ticket, then serves it to everyone who views that page later. No link needed: the victims come to the payload. This is the most damaging type because one injection can hit every visitor, including an admin opening a dashboard.

DOM-based XSS never needs the server to mishandle anything. The vulnerability is in client-side JavaScript that reads a source (the URL, the fragment after #, document.referrer) and writes it into a dangerous sink such as innerHTML. The payload can live entirely in the part of the URL the browser never sends to the server, which is why server logs can look clean.

TypeWhere the payload livesWhen it firesFlaw sits in
ReflectedIn the request (URL or body)When the victim loads the crafted requestServer-side output
StoredSaved on the server, served laterWhen any victim views the pageServer-side output
DOM-basedIn a source read by client JSWhen the page script runs in the browserClient-side JavaScript

03The output context decides the payload

The output context is the single most useful idea in XSS, because the same input is safe in one place and dangerous in another. Where your value lands in the page decides what characters you need to break out and what the browser will treat as code. A payload tuned for the HTML body falls flat inside a JavaScript string, and the other way around.

There are four contexts you will meet constantly. Each one has a different escape and a different proof.

ContextExample sinkWhat breaks out
HTML body<div>HERE</div>Inject a tag directly, such as an image with an error handler
HTML attribute<input value="HERE">Close the quote and the tag, or add an event handler attribute
JavaScriptvar s = "HERE";Close the string and statement, then write your own code
URL<a href="HERE">Use a javascript: scheme so the link runs code

The break-out characters follow from the context. In an HTML attribute you need a quote and an angle bracket; in a JavaScript string you need a quote and a semicolon. The payloads below each target one context with a benign proof.

html
<!-- HTML body: inject a tag whose handler runs on a failed load -->
<img src=x onerror=alert(document.domain)>

<!-- HTML attribute: value sits inside quotes, so close them first -->
"><script>alert(document.domain)</script>

<!-- JavaScript string: close the string and the statement -->
";alert(document.domain);//
Match, do not guess

Always read the response and see exactly which context your marker lands in before you build a payload. Half of failed XSS attempts are a body payload fired into an attribute, or the reverse.

04Finding a reflection and confirming execution

Finding a reflection starts with a unique marker, not a payload. Send a harmless string you can grep for, find where it comes back, read the surrounding context, and only then craft the proof that fits. This keeps you from spraying <script> at every field and learning nothing.

Plant a marker

Put a distinctive, inert string such as zx9k7q into each parameter and header you control.

Find every reflection

Search the whole response for the marker. One input often lands in several places, each in a different context.

Read the context

Look at the characters around the marker: inside a tag body, inside quotes, inside a script block, inside an href.

Probe the special characters

Send <>"' and check which come back raw and which come back encoded. Raw means that character is available to you.

Prove execution

Fire the smallest context-appropriate payload with a benign alert(document.domain) and confirm it runs in the browser.

the marker comes back raw inside the HTML body
$ curl -s "http://lab.0xpriv.dev/search?q=zx9k7q" | grep -o "....zx9k7q...."
for zx9k7q</
$ curl -s "http://lab.0xpriv.dev/search?q=<b>zx9k7q</b>" | grep -o "<b>zx9k7q</b>"
<b>zx9k7q</b>

The second request is the tell: the angle brackets came back untouched, so the parser will accept a real tag here. A browser, not curl, confirms execution, because curl only prints bytes and never runs script. Load the crafted URL in a devtools session and watch the alert fire.

05A worked reflected example on lab.0xpriv.dev

The reflected example puts everything together against the vulnerable search endpoint from earlier. The marker landed raw in the HTML body, so the body-context payload is a tag the browser will run on its own.

html
<!-- 0xpriv.dev/web : benign proof, HTML body context -->
http://lab.0xpriv.dev/search?q=<script>alert(document.domain)</script>

<!-- URL-encoded for a clean link to hand to a test victim -->
http://lab.0xpriv.dev/search?q=%3Cscript%3Ealert(document.domain)%3C%2Fscript%3E

Open that link and the alert shows the page's own domain, which proves the script ran in the origin of lab.0xpriv.dev, not in a sandbox. If the value had landed inside an input attribute instead, the body payload would do nothing and you would switch to the attribute break-out from the context table.

the payload is reflected verbatim into the response
$ curl -s "http://lab.0xpriv.dev/search?q=<script>alert(document.domain)</script>" | grep script
<h1>Results for <script>alert(document.domain)</script></h1>
Reflected needs a click

Reflected XSS only hits someone who loads your crafted request, so real delivery is a link in an email or message. In a lab you are that victim, which is why you confirm it in your own browser.

06A stored example on lab.0xpriv.dev

The stored example moves the payload from a link into the database. A comment box saves whatever you type and renders it back to every visitor with no encoding, so one submission plants a script that runs for each person who opens the thread.

php
// the input is saved safely as data (parameterized, so no SQL injection)
$pdo->prepare('INSERT INTO comments (body) VALUES (?)')->execute([$_POST['body']]);

// ... but later it is echoed into the page with no output encoding
foreach ($comments as $c) {
    echo "<div class='comment'>" . $c['body'] . "</div>";  // the stored sink
}

Notice the insert is parameterized, so there is no SQL injection here. Storing data safely and rendering it safely are two different jobs, and XSS is a failure of the second one. Submit the proof below as a comment, and it fires in the browser of the next visitor, including a moderator reviewing the queue.

html
<!-- benign stored proof: runs for every viewer of the page -->
<img src=x onerror=alert(document.domain)>

This is why stored XSS outranks reflected on severity. There is no link to deliver and no victim to lure, the payload sits on a trusted page and waits. An injection in a shared field such as a username, a ticket subject, or a product review can reach staff accounts with far more access than the attacker has.

07DOM-based XSS: when the sink is in the browser

DOM-based XSS lives entirely in client-side JavaScript, so the server can be doing everything right and the page is still vulnerable. The flaw is a script that reads an attacker-controlled source and hands it to a sink that turns strings into markup or code. The classic pair is the URL fragment as the source and innerHTML as the sink.

javascript
// vulnerable client code: source is the URL fragment, sink is innerHTML
const q = decodeURIComponent(location.hash.slice(1));
document.getElementById('out').innerHTML = q;   // DOM sink, no encoding

// proof URL, note the payload is after # so it never reaches the server:
// http://lab.0xpriv.dev/app#<img src=x onerror=alert(document.domain)>

Two details matter here. First, innerHTML will not run a bare <script> tag inserted this way, which is why the proof uses an image with an onerror handler that the browser does run. Second, everything after the # stays in the browser and is never sent in the request, so this attack can leave no trace in access logs at all. Dangerous sinks to grep for in front-end code include innerHTML, outerHTML, document.write, insertAdjacentHTML and eval. The OWASP DOM-based XSS prevention cheat sheet lists the full set of sources and sinks worth auditing.

08What an attacker actually does with it

The real impact of XSS is that your code runs with the full trust the browser gives that origin, so it can do anything the logged-in user can do. An alert box is only a proof; the interesting part is what the same execution primitive enables, described here at the concept level rather than as a ready-made weapon.

What the execution primitive buys
Session and cookie theft. If the session cookie is not marked HttpOnly, script can read document.cookie and send it to a server the attacker controls, letting them ride the victim's session without a password.
Keylogging and form capture. An event listener on the page can record keystrokes or read form fields as they are typed, including data that never leaves the client otherwise.
Request forgery in the victim's browser. The script can issue authenticated requests as the user (change an email, add an account, approve a transfer), carrying their cookies and defeating same-origin assumptions from the inside.
Account takeover reach. On stored XSS in an admin-visible field, one injection can run inside a staff session and pivot to the highest-privileged account on the app.

The point for a defender is that none of these need a second bug. Once script runs in your origin, the browser's trust model is working for the attacker, so the fix has to stop the execution, not the individual payload.

09Detecting XSS in logs and at the WAF

Detecting XSS attempts leans on the shape of the payloads and the behavior around them, because signatures alone are easy to dodge. In access logs and request bodies, injection probes show up as angle brackets, onerror= and other event handlers, javascript: schemes, and encoded variants such as %3Cscript or HTML entities, clustered on the same parameter as a tester iterates. A burst of requests to one field, each a slightly different break-out attempt, is a louder signal than any single string.

A web application firewall catches the obvious patterns, but treat it as a speed bump: encoding tricks, case changes, and alternate event handlers routinely slip past rule sets, and DOM-based payloads after a # never reach the server to be inspected at all. The more durable detection is on the defense side. A Content-Security-Policy with a report-uri or report-to directive tells the browser to report blocked inline scripts, which surfaces both real attacks and your own injection flaws as they get exercised in the wild.

10Shutting down cross-site scripting

Shutting down XSS comes back to one rule: treat every piece of untrusted data as text for the exact context it lands in. Output encoding is the primary control, and the layers below it reduce the damage when something slips through. The fix is never a blocklist of bad words, because the contexts and encodings are too varied to enumerate.

php
// context-aware output encoding for the HTML body and attributes
echo htmlspecialchars($_GET['q'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
http
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax
How to shut it down
Context-aware output encoding. Encode on the way out for the exact context: HTML body, attribute, JavaScript, and URL each need a different escaping. This is the control that actually closes the bug.
Let the framework auto-escape. Modern templates (React, Vue, Twig, Razor) escape by default. Audit every escape hatch such as dangerouslySetInnerHTML or raw output filters by hand.
Add a Content-Security-Policy. A policy that forbids inline script and restricts sources turns many injections into a blocked, reported event instead of code execution. It is defense in depth, not a substitute for encoding.
Mark session cookies HttpOnly. This keeps script from reading the cookie, which removes the easiest path to session theft even if a payload does run.
Validate input, but do not rely on it. Reject obviously wrong input early, yet remember validation narrows the surface and encoding is what closes it.

For DOM-based cases, keep untrusted data away from dangerous sinks: assign with textContent instead of innerHTML, and where rich HTML is genuinely needed, run it through a vetted sanitizer before it reaches the DOM.

11FAQ

What is the difference between reflected and stored XSS?FAQ

Reflected XSS carries the payload in the request and bounces it straight back in that same response, so it only fires for someone who loads your crafted link. Stored XSS saves the payload on the server and serves it to every later visitor of the page, so it needs no link and reaches more people, which makes it the more severe of the two.

Is DOM-based XSS a server bug or a browser bug?FAQ

It is a client-side bug. The vulnerable code is JavaScript that reads an attacker-controlled source such as the URL fragment and writes it into a dangerous sink like innerHTML. The server can handle every request correctly and the page is still exploitable, and because the payload can sit after the # it may never appear in server logs.

Why does my payload work in one field but not another?FAQ

Because the output context is different. A tag that runs in the HTML body does nothing inside a quoted attribute or a JavaScript string, where you first have to break out of the quotes or the statement. Read where your marker lands in the response, then pick the break-out that matches that exact context.

Does a Content-Security-Policy replace output encoding?FAQ

No. CSP is defense in depth that can block inline script and limit where script loads from, which downgrades many injections to a reported, non-executing event. But policies can be misconfigured or bypassed, so correct context-aware output encoding stays the primary fix, with CSP as a strong second layer.

Can I just filter out the word script to stop XSS?FAQ

No, blocklists lose. Execution does not need the word script: event handlers like onerror, javascript: URLs, SVG, mixed case, and encoded variants all run code. Encode output for its context and let the framework escape by default instead of trying to enumerate bad strings.

12Wrapping up

XSS reduces to one sentence: untrusted data reached a sink and the browser ran it as code. Reflected bounces it back in the response, stored keeps it on the page for every visitor, and DOM-based builds it in the browser from a source the server never sees. Learn to read the output context first, prove execution with a benign alert on a lab, and remember that the whole class closes when every value is encoded for the exact place it lands and session cookies are kept out of script's reach.

references

Was this writeup useful?
Alex Rowan Founder & Security Researcher at 0xpriv

Founder of 0xpriv, covering offensive security, web application vulnerabilities, and practical security research through PHP, Python, and hands-on technical analysis.

related writeups

more in Web →

discussion

0 comments
`code` supported · comments are reviewed before they appear