If you already know the basics of cross site scripting, dom based xss is the variant that surprises people. The payload never reaches the server. The whole bug lives in client side JavaScript that reads attacker controlled input and writes it into the page in an unsafe way. The HTML the server sends can be perfectly clean, and the page still runs attacker code.
What makes dom based xss different
Stored and reflected XSS both pass through the server, which either saves the payload or echoes it back into the response body. So a server side filter, a template that escapes output, or a web application firewall all get a chance to see the input and stop it.
DOM based XSS skips that path. The browser loads a clean page, then JavaScript on that page reads something the attacker controls and feeds it into a part of the DOM that turns text into code. The server may never receive the malicious value at all. This is why people call it a client side bug. The flaw is in the script the site already ships, not in any HTML the backend builds.
In a dom based xss bug the dangerous step happens after the page has loaded, inside JavaScript the site wrote, using input the server may never see.
Sources: where the attacker controlled input comes in
A source is any place client JavaScript reads input that an attacker can influence. To find these bugs, learn the common sources by name and grep your code for them:
location.hash, the part of the URL after the#. The browser never sends this to the server, so it is the classic source for a bug the backend cannot see.location.search, the query string. The server can read this too, but if JavaScript also reads it and writes it into the DOM, you have a client side path that bypasses server escaping.document.referrer, the URL of the page that linked here. An attacker controls it by hosting the linking page.postMessagedata. A handler that trustsevent.datawithout checkingevent.origintakes input straight from any page that can reach the frame.- Stored values like
localStorageor a cookie that some other flow let the attacker set earlier.
Sinks: where that input becomes code
A sink is a DOM API that can turn a string into markup or executable code. Input from a source is only dangerous when it reaches a sink. Watch these:
innerHTMLandouterHTML, which parse a string as HTML.document.writeanddocument.writeln, which inject HTML straight into the parser.eval,setTimeoutwith a string,setIntervalwith a string, and theFunctionconstructor, which run a string as JavaScript.setAttributewhen you set an event handler or anhrefthat starts withjavascript:.- jQuery sinks like
$(el).html(value), and also$()itself when you pass it a string that looks like HTML.
The bug is the join: a source flows into a sink with no encoding or validation in between. Find that flow and you have found the vulnerability. How the browser interprets a response can widen these sinks too, since a missing or weak content type lets the browser guess and run bytes you meant as data, which our free MIME sniffing checker inspects for you.
A concrete example on Acme Notes
Acme Notes is an invented app, a small site where people keep public notes. It is not a real product. The notes page shows a banner using the part of the URL after the #, so people can bookmark a link that greets them by name.
Here is the vulnerable flow, source to sink:
// SOURCE: location.hash, never sent to the server
const name = decodeURIComponent(location.hash.slice(1));
// SINK: innerHTML parses the string as HTML
document.getElementById('banner').innerHTML = 'Welcome back, ' + name;
With a normal link like https://acme-notes.example/#Riley the banner reads Welcome back, Riley and everything is fine. Now an attacker shares this link:
https://acme-notes.example/#<img src=x onerror=alert(document.domain)>
The browser loads Acme Notes, the script reads the hash, and innerHTML parses it into a real img element. The image fails to load, the onerror handler runs, and the script executes on the Acme Notes origin. A real attacker would replace the alert with code that reads the session token. The victim only had to click a link.
Why server side filters do not catch dom based xss
Look again at the link. Everything after the # stays in the browser. The server gets a request for / with no payload attached. So none of the usual server side defenses ever see the attack:
- A web application firewall inspecting request bodies and query strings sees nothing, because the value is in the fragment.
- A template engine that escapes output does not help, because the server never renders this value. The browser does.
- Input validation on the API has no input to validate.
Even when the source is location.search, which the server does receive, escaping it for the response body does nothing for a second, separate read by JavaScript on the client. The protection has to live where the bug lives, in the browser.
How to fix it
The fix is to keep attacker input as data on the client, the same principle as server side XSS, applied to DOM APIs. Here is the corrected Acme Notes banner next to the safe options:
// FIX 1: textContent treats the value as plain text, never as HTML
const name = decodeURIComponent(location.hash.slice(1));
document.getElementById('banner').textContent = 'Welcome back, ' + name;
// FIX 2: build nodes with safe DOM APIs instead of HTML strings
const span = document.createElement('span');
span.textContent = name;
banner.append('Welcome back, ', span);
Beyond that single line, these habits prevent the whole class:
- Use
textContentinstead ofinnerHTMLwhenever you only need to show text. - Let a framework do the escaping. React, Vue, and Angular escape interpolated values by default, so the danger is the explicit escape hatch like
dangerouslySetInnerHTMLorv-html. - Turn on Trusted Types with a Content Security Policy header. It blocks strings from reaching sinks like
innerHTMLunless they pass through a policy you wrote:Content-Security-Policy: require-trusted-types-for 'script'. Our free Content Security Policy generator can build a strict policy with that directive included. - If you truly need to render user HTML, run it through a maintained sanitizer such as DOMPurify, or the built in Sanitizer API where it is available, before it touches a sink.
- For
postMessage, checkevent.originagainst an allow list before you trustevent.data.
Self XSS and when it stops being harmless
Some DOM sinks only fire on input the victim types into their own browser, like a value pasted into the developer console or a field only that user can edit. That is self XSS, and on its own it is low impact, because a person can only attack themselves. Treat it carefully though. Self XSS can be upgraded into a real attack when it is chained with another bug that delivers the payload for the victim, for example a way to seed localStorage or set a value through a separate request. A finding that looks self inflicted may become serious once you connect it to a second hole, so it is worth verifying the full chain rather than dismissing it.
Finding these flows in practice
Spotting dom based xss is source to sink tracing. List every source the page reads, follow each value through the code, and flag any that reaches a sink without encoding. This is tedious by hand because the flow can cross functions, event handlers, and third party scripts. For related input bugs and the broader XSS coverage on this site, see our injection and input category.
The harder cases depend on how a page assumes its own data behaves, like a value that is safe in one handler and piped raw into a sink in another. Those gaps show up when you understand what the app expects, not when you replay a fixed payload list. This is exactly the kind of bug an autonomous researcher that tests an app’s assumptions is built to find and then prove with real evidence. Read more about that approach on our about page.
Frequently asked questions
How is DOM based XSS different from reflected or stored XSS?
Reflected and stored XSS both pass through the server, which echoes or saves the payload, so server side filters and escaping get a chance to stop it. DOM based XSS happens entirely in client side JavaScript that reads attacker controlled input and writes it into the page, so the server may never see the malicious value at all. That is why the protection has to live in the browser.
What are sources and sinks in DOM based XSS?
A source is any place client JavaScript reads input an attacker can influence, like location.hash, location.search, or document.referrer. A sink is a DOM API that turns a string into markup or code, like innerHTML, document.write, or eval. The bug is the join: a source flows into a sink with no encoding in between.
Why can a web application firewall miss DOM based XSS?
When the source is location.hash, everything after the # stays in the browser and is never sent to the server, so a firewall inspecting request bodies and query strings sees nothing. Even with location.search, which the server does receive, escaping it for the response body does nothing for a second, separate read by JavaScript on the client. The PortSwigger Web Security Academy guide on DOM based XSS walks through these source to sink flows.
Is self XSS always harmless?
Mostly it is low impact, because a self XSS sink only fires on input the victim types into their own browser, so a person can only attack themselves. It stops being harmless when it is chained with another bug that delivers the payload for the victim, for example a way to seed localStorage or set a value through a separate request. It is worth verifying the full chain rather than dismissing it.
