Web and API security
OWASP Top 10 Explained for Developers
What the OWASP Top 10 is, what each 2025 category means, how to defend it, and how to map Juice Shop challenges to it.
Learn reflected, stored and DOM-based XSS, why output encoding depends on context, and how CSP adds defence in depth, in a local lab.
Cross-site scripting (XSS) happens when a web application takes data that an attacker can influence and places it into a page in a way the browser reads as code. The browser then runs that code with the privileges of the page's origin. It can read the page, send requests as the user, and change what the user sees.
The core problem is not that a script comes from another site. The problem is that the application mixes data and code, and the browser cannot tell which part came from the developer and which part came from a user. MITRE classifies it as CWE-79, "Improper Neutralization of Input During Web Page Generation." That wording matters: the weakness is a failure to neutralize input at the point where the page is built.
XSS is one of the most common web vulnerabilities, and it stays common because it hides in ordinary features: a search box that echoes the query, a comment field, a profile name, a URL fragment that a single-page app reads on load. A successful attack runs inside the victim's logged-in session. It can read whatever the page can read, trigger actions the user is allowed to take, or show a fake login form. Browsers limit some of this through the same-origin policy, but XSS works precisely because it runs inside the origin the user already trusts.
For a developer, the practical lesson is narrow and useful. You do not need to filter every bad string. You need to make sure untrusted text is never parsed as markup or code by the browser.
OWASP describes three families. They differ by where the untrusted data travels before it reaches the browser.
Reflected XSS. The server takes a value from the current request, usually a query parameter or form field, and returns it in the response without safe encoding. The attacker builds a link that carries the payload. The attack needs the victim to open that link. The payload is not stored anywhere on the server.
Stored XSS. The application saves attacker-controlled input, such as a comment, a username or a support ticket, and later renders it to other users. The victim does not need a special link. Viewing the normal page is enough, which is why stored XSS usually has the largest impact.
DOM-based XSS. The server may never see the payload at all. Client-side JavaScript reads a source the attacker can control, such as location.hash, location.search, document.referrer or a message from postMessage, and writes it into a dangerous sink such as innerHTML, document.write or eval. The bug lives in the browser code. Server-side filters never see the attack, which is why this type surprises teams that only review backend code.
The three types share one root cause. Untrusted data reaches a context that interprets it as code.
Output encoding is the main defence, and it is not one operation. The correct encoding depends on the place where the value lands. The OWASP XSS Prevention Cheat Sheet lists the rules for each context. The short version:
| Context where the value lands | What to do | Common mistake |
|---|---|---|
| HTML body | HTML entity encoding of &, <, >, ", ' |
Encoding twice, which shows &lt; on the page |
| HTML attribute | Encode and always quote the attribute value | Leaving the value unquoted, or assuming this protects href or event handlers |
| JavaScript string | Use a library encoder for that exact context | Escaping for HTML and then inserting the value into a <script> block |
| URL parameter | Percent-encode the value with encodeURIComponent(), and allow only http and https schemes for whole URLs |
Treating encoding as enough for a full URL taken from a user: a javascript: scheme still runs when it lands in href |
| CSS | Do not put untrusted data into CSS; set fixed properties through the CSSOM | Building a style attribute string from user input |
The table shows why "sanitize the input" is an incomplete rule. A string that is safe in a paragraph can be dangerous inside an href attribute, because javascript: is a valid URL scheme there. The same string is harmless when written with textContent. Encoding has to match the sink the value reaches at the moment it is written.
The OWASP cheat sheet also warns that hex or Unicode escapes alone are not a substitute for a context-aware JavaScript encoder, and that eval(), setTimeout() and setInterval() should not receive untrusted strings at all. Encode on output and keep those functions away from untrusted paths.
You can reduce the number of decisions by choosing sinks that treat input as text. Setting element.textContent or element.value writes characters, not markup. Setting element.innerHTML runs an HTML parser. The OWASP cheat sheet names innerHTML as a sink to avoid for untrusted data and recommends textContent or value instead.
Modern frameworks such as React, Angular, Vue and Svelte escape values in their default template bindings. A value written as {userName} in JSX or as an interpolation in a template becomes text. The risk returns when developers use an escape hatch: dangerouslySetInnerHTML in React, bypassSecurityTrustHtml in Angular, v-html in Vue, or a direct DOM write from a library. Those escape hatches are where XSS usually enters a framework app. Treat each use as a review item and keep a list of them.
A Content Security Policy (CSP) is a response header, or a <meta http-equiv> element, that tells the browser which sources of scripts, styles, images and other resources a page may use. MDN describes its main purpose as controlling which resources a document can load, which defends against XSS. The directive default-src sets the fallback for every resource type that lacks its own directive.
With default-src 'self', the browser refuses inline scripts and inline event handlers such as onerror="...", and it refuses javascript: URLs in the places where they would run code. A CSP does not fix a bug in your HTML. It limits what an injected payload can do. That is why it is defence in depth: encoding stops the injection, and CSP makes a missed injection less useful.
Two limits matter. A <meta> policy does not support every CSP feature, and MDN notes that report-only policies must be sent as HTTP headers. Check the MDN page for the directives your browsers honour in <meta> before you rely on one. A policy that allows 'unsafe-inline' or a wide list of hosts gives much less protection.
This lab has two parts. First, you solve the DOM XSS challenge in OWASP Juice Shop, a deliberately vulnerable application, running on your own machine. Second, you build a small page that writes the same untrusted text to two different sinks, and you watch what the browser does.
Run every step on a machine you control. Keep the services bound to 127.0.0.1. Do not point these payloads at a site you do not own or have written permission to test.
Confirm Docker responds:
docker --version
docker info --format '{{.ServerVersion}}'On the machine used for this article, the first command printed Docker version 29.8.2, build 7fc2dff. Your version may differ.
Start the container with a loopback-only port mapping:
docker run -d -p 127.0.0.1:3000:3000 bkimminich/juice-shopThe 127.0.0.1: prefix publishes the port only on your loopback interface. If port 3000 is already in use, pick another host port, such as 127.0.0.1:3001:3000, and change the URLs below to match.
Check that the application answers and read its version:
curl -s http://127.0.0.1:3000/rest/admin/application-versionOn the machine used for this article, the response was {"version":"20.3.0"}. The first start can take a little while, so repeat the request if you get no answer.
The official companion guide for Juice Shop has a section called "Perform a DOM XSS attack." Its hint says to look for an input field whose content appears in the HTML when the form is submitted. The guide gives this payload:
<iframe src="javascript:alert(`xss`)">Follow the guide in your own browser. Open the Juice Shop search bar, paste the payload, and submit it. A search URL with the query in the fragment also works. On the machine used for this article, that URL was loaded in headless Chrome, and the scoreboard then reported the challenge as solved:
http://127.0.0.1:3000/#/search?q=%3Ciframe%20src%3D%22javascript%3Aalert(%60xss%60)%22%3EQuery the challenge through the application's API to check its status:
curl -s "http://127.0.0.1:3000/api/Challenges/?name=DOM%20XSS" | grep -o '"solved":[a-z]*'Before the search URL was loaded, the output was "solved":false. After loading it, the output was "solved":true. Your result should match once you have triggered the challenge in a browser.
Note what made this a DOM-based bug. The payload sits in the fragment, which the browser does not send to the server, and the client-side code writes it into the page. A server log would show only requests for the application's HTML and scripts.
Create a folder and go into it:
mkdir xsslab && cd xsslabCreate index.html. This file reads the text after #, writes it once with innerHTML and once with textContent, and adds the CSP meta element from the lab brief:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'">
<title>XSS sink lab</title>
</head>
<body>
<h1>Sink comparison</h1>
<p>Put text after the # in the URL, for example <code>#hello</code>.</p>
<h2>innerHTML</h2>
<div id="html-sink"></div>
<h2>textContent</h2>
<div id="text-sink"></div>
<script src="app.js"></script>
</body>
</html>Create app.js in the same folder. The script lives in a separate file on purpose. An inline <script> block would also be blocked by the policy, and that would hide the comparison you want to make.
// Read the fragment once, then write it to two different sinks.
const raw = decodeURIComponent(location.hash.slice(1));
document.getElementById('html-sink').innerHTML = raw;
document.getElementById('text-sink').textContent = raw;Serve the folder on loopback only. On Windows the Python launcher is usually python; on Linux and macOS it is often python3:
python -m http.server 8000 --bind 127.0.0.1Keep that terminal open while you work. Open http://127.0.0.1:8000/index.html in your browser. Stop the server with Ctrl+C when you are done.
Open the browser's developer tools and the Console tab. Load the page with a harmless value first:
http://127.0.0.1:8000/index.html#helloThen run these two lines in the console:
document.getElementById('html-sink').innerHTML
document.getElementById('text-sink').textContentBoth show hello. Now load a fragment that mixes markup and an event handler:
http://127.0.0.1:8000/index.html#hello%20%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3EI loaded this exact URL in headless Chrome 156.0.8078.80 and dumped the resulting DOM. The relevant output was:
<div id="html-sink">hello <img src="x" onerror="alert(1)"></div>
<div id="text-sink">hello <img src=x onerror=alert(1)></div>The innerHTML sink created an img element. The textContent sink stored the same characters as plain text. Run the two console lines again on this URL and check that the first returns markup with an element in it and the second returns the escaped string.
The page above already includes default-src 'self'. The same headless run logged this message:
Executing inline event handler violates the following Content Security Policy directive 'default-src 'self''.The img element was created, but its onerror handler did not run alert(1). The policy stopped the inline handler. Your console should show a similar message. The exact wording can differ between browser versions.
To see the difference, remove the meta element, load the same URL again, and watch for the alert. Then put the meta element back. This comparison is the only step in the lab where the payload is meant to execute.
Keep one point in mind. The policy did not make the innerHTML sink safe. It blocked one kind of execution. The img element still exists in the DOM, and an attacker who finds a payload the policy allows could still act on it. The correct fix is to write untrusted text with textContent.
Escaping once for the wrong context. HTML encoding protects text in a paragraph. It does not protect a URL, a JavaScript string or a CSS value. Match the encoder to the place where the value lands.
Filtering for known bad strings. Blocklists that remove <script> miss <img onerror>, <svg onload> and javascript: URLs. Encoding and safe sinks work because they do not depend on guessing the attacker's next string.
Trusting the framework blindly. Default bindings escape values, but dangerouslySetInnerHTML, v-html and bypassSecurityTrustHtml skip that protection. Search your code for them.
Reading only server code. DOM XSS never reaches the server. Review client code that reads location, document.referrer and postMessage data.
Using CSP as the only control. A strict policy helps, but it does not replace encoding, and a weak policy with 'unsafe-inline' offers little protection.
Assuming the meta policy has every feature. A policy in <meta> cannot use report-only mode or every directive. If you need reports, send the policy as a response header.
Read the OWASP Cross Site Scripting Prevention Cheat Sheet from start to finish. It is short for a security reference and it covers every context in the table above. Then read the MDN CSP guide and try a stricter policy with nonces or hashes on your lab page.
Work through the other Juice Shop challenges that involve XSS, and compare how the stored and reflected variants differ from the DOM case you just solved. Keep the lab on loopback, and stop the container when you are finished:
docker ps --filter ancestor=bkimminich/juice-shop
docker stop <container-id>The next article in this series covers cross-site request forgery and how SameSite cookies reduce one of its paths. XSS and CSRF are often confused, and the difference is worth knowing before you design a session model.
Web and API security
What the OWASP Top 10 is, what each 2025 category means, how to defend it, and how to map Juice Shop challenges to it.
Web and API security
How SQL injection changes a query's meaning, why parameterized queries fix it, and a Juice Shop login lab with a Python sqlite3 demo.
Web and API security
Cross-site request forgery uses your browser's cookies against you. Learn the defences in order of strength and test SameSite with a Flask lab.