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.
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.
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.
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.
// 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 < 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.
| Type | Where the payload lives | When it fires | Flaw sits in |
|---|---|---|---|
| Reflected | In the request (URL or body) | When the victim loads the crafted request | Server-side output |
| Stored | Saved on the server, served later | When any victim views the page | Server-side output |
| DOM-based | In a source read by client JS | When the page script runs in the browser | Client-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.
| Context | Example sink | What 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 |
| JavaScript | var 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 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);//
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.
$ 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.
<!-- 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.
$ 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 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.
// 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.
<!-- 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.
// 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.
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.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.
// context-aware output encoding for the HTML body and attributes echo htmlspecialchars($_GET['q'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none' Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax
dangerouslySetInnerHTML or raw output filters by hand.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
Founder of 0xpriv, covering offensive security, web application vulnerabilities, and practical security research through PHP, Python, and hands-on technical analysis.