Cross-Site Scripting (XSS)
Cross-site scripting (XSS) lets an attacker run their own JavaScript in other users' browsers, inside a trusted website. The browser cannot tell the difference between the site's real scripts and the injected one, so the attacker's code can do anything the logged-in user can: read the page, send requests as the user, show a fake login box, or steal a session cookie that is not protected.
In this lesson you will learn the three types of XSS, why the context of the output matters, how to test for XSS safely in your lab, and the layered defences that prevent it: output encoding, safe DOM APIs, sanitisation and Content Security Policy.
How XSS happens
XSS is a form of injection (part of A05:2025). It happens when an application puts untrusted data into a page without encoding it for that place. Here is the mistake and the fix side by side in Python:
import html
comment = 'Great saree! <img src=x onerror="stealCookies()">'
unsafe_page = f"<p>{comment}</p>"
safe_page = f"<p>{html.escape(comment)}</p>"
print("Unsafe:", unsafe_page)
print("Safe: ", safe_page)Unsafe: <p>Great saree! <img src=x onerror="stealCookies()"></p> Safe: <p>Great saree! <img src=x onerror="stealCookies()"></p>
In the unsafe page the browser sees a real <img> element; the image fails to load, so its onerror code runs. In the safe page, html.escape() turns <, >, & and quotes into HTML entities, so the browser displays the text instead of treating it as markup.
Three types of XSS
| Type | Where the payload lives | Example |
|---|---|---|
| Reflected | In the request (often a URL parameter) and echoed straight back | A search page that prints "No results for ..." with the raw search term |
| Stored | Saved on the server and shown to everyone who views the page | A product review or profile name containing script |
| DOM-based | Never touches the server's HTML; the site's own JavaScript writes it into the page | Code that copies location.hash into innerHTML |
Stored XSS is usually the most serious, because every visitor is affected without clicking a special link. Reflected XSS needs the victim to open a crafted link, which attackers send by email or chat.
Context matters
Encoding must match the place where data goes. The same value needs different handling in HTML text, in an attribute, in a URL and inside JavaScript:
| Output context | Safe approach |
|---|---|
HTML text, such as <p>...</p> | HTML-encode (html.escape, htmlspecialchars, template auto-escaping) |
| Quoted HTML attribute | HTML-encode including quotes, and always quote attributes |
URL in href or src | Allow only https: (and maybe http: or mailto:), then HTML-encode |
Inside a <script> block | Avoid; pass data via a JSON data attribute or json_encode/json.dumps with care |
| CSS | Avoid putting user data in styles |
Links are a classic trap. HTML encoding does not stop a link to javascript:..., because that string contains no special HTML characters:
import html
from urllib.parse import urlsplit
ALLOWED_SCHEMES = {"https", "http", "mailto"}
def safe_link(url, text):
scheme = urlsplit(url.strip()).scheme.lower()
if scheme not in ALLOWED_SCHEMES:
url = "#"
return f'<a href="{html.escape(url)}">{html.escape(text)}</a>'
print(safe_link("https://shop.example/offers?a=1&b=2", "Offers"))
print(safe_link("javascript:stealCookies()", "Click me"))
print(safe_link(" JaVaScRiPt:alert(1)", "Tricky"))<a href="https://shop.example/offers?a=1&b=2">Offers</a> <a href="#">Click me</a> <a href="#">Tricky</a>
Checking the scheme with an allow-list handles odd spacing and mixed case, which a simple block-list of the word "javascript" would miss.
Testing for XSS in your lab
DVWA has three practice pages: XSS (Reflected), XSS (Stored) and XSS (DOM), and Juice Shop includes DOM-based and stored XSS challenges. A safe way to start is with a harmless marker instead of a script: enter <b>xss-test-01</b> into a field. If the page shows xss-test-01 in bold, your input is being treated as HTML, which means the page is injectable. If it shows the tags as text, the output is encoded.
Then use your browser's developer tools to inspect exactly where the marker appears (text, attribute or script) because that decides which defence is missing. Compare DVWA's low and impossible levels with "View Source" to see the encoding added in the secure version. Never test XSS against websites you are not authorised to test, and never use payloads that send data to outside servers.
Defence 1: encode output, using your framework
Modern template engines encode by default: Jinja2 and Django templates in Python, Twig and Blade in PHP, and JSX in React all escape values unless you deliberately turn it off (|safe, {!! !!}, dangerouslySetInnerHTML). Treat every use of those "raw" features as a code review red flag. In plain PHP, wrap output in htmlspecialchars(), as shown in PHP Security Essentials.
Defence 2: safe DOM APIs
In JavaScript, use textContent for text. innerHTML parses its value as HTML, which is how most DOM-based XSS happens:
const name = new URLSearchParams(location.search).get("name") ?? "";
// Unsafe: the browser parses name as HTML
greeting.innerHTML = "Welcome, " + name;
// Safe: the browser treats name as plain text
greeting.textContent = "Welcome, " + name;If users must submit rich text (bold, links), clean it with a well-tested HTML sanitiser such as DOMPurify, which keeps safe tags and removes scripts and event handlers. Do not write your own sanitiser with regular expressions.
Defence 3: Content Security Policy
A Content Security Policy (CSP) header tells the browser which scripts may run. A strict policy allows only scripts carrying a random nonce generated fresh for each response, so an injected script without the nonce is blocked even if encoding was missed somewhere.
def csp_header(nonce):
directives = {
"default-src": "'self'",
"script-src": f"'nonce-{nonce}' 'strict-dynamic'",
"object-src": "'none'",
"base-uri": "'none'",
"frame-ancestors": "'none'",
}
return "Content-Security-Policy: " + "; ".join(f"{k} {v}" for k, v in directives.items())
# In a real app: nonce = secrets.token_urlsafe(16), new for every response
print(csp_header("rAnd0mN0nce123"))Content-Security-Policy: default-src 'self'; script-src 'nonce-rAnd0mN0nce123' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
Each allowed <script> tag then carries nonce="rAnd0mN0nce123" (the real, random value). Add the HttpOnly flag to session cookies too, so even a successful XSS cannot read them directly.
Common mistakes
- Encoding input when it arrives instead of output when it is shown. Store the original; encode for the exact context at output time.
- Using a block-list (removing
<script>). Attackers have hundreds of other tags and event handlers. - Turning off auto-escaping "just for this one field" in a template.
- Assuming XSS is harmless because it "only shows an alert". The same hole can run any script as the victim.
- A CSP with
'unsafe-inline'inscript-src, which allows injected inline scripts and removes most of the protection.
Key points
- XSS runs attacker JavaScript in other users' browsers inside your site.
- Reflected XSS comes from the request, stored XSS from saved data, DOM-based XSS from client-side code.
- Encode output for its context; check URL schemes with an allow-list.
- Use template auto-escaping,
textContentinstead ofinnerHTML, and a trusted sanitiser for rich text. - A nonce-based CSP and
HttpOnlycookies limit the damage when something is missed. - Practise only on DVWA and Juice Shop in your lab, starting with a harmless marker.
Exercise
Write a function render_review(name, rating, text) that returns an HTML snippet like <div class="review"><b>NAME</b> (RATING/5)<p>TEXT</p></div>. Make it safe: HTML-encode the name and text, and accept only whole-number ratings from 1 to 5 (anything else becomes 0). Test it with a name containing <script> and a rating of "5 onclick=x".
Show solution
import html
def render_review(name, rating, text):
try:
stars = int(rating)
except ValueError:
stars = 0
if not 1 <= stars <= 5:
stars = 0
return (f'<div class="review"><b>{html.escape(name)}</b> ({stars}/5)'
f'<p>{html.escape(text)}</p></div>')
print(render_review("<script>bad()</script>Rohan", "5 onclick=x", "Fast delivery to Kochi & good quality"))The name's tags become <script>, the & in the text becomes &, and the rating fails int() so it shows as 0/5. Validating the number and encoding the strings closes both injection points.
Quick Quiz
1. Which type of XSS is saved on the server and shown to every visitor of a page?
Stored XSS lives in the database, for example in a review or profile name.
2. Why does HTML encoding alone not make a user-supplied link safe?
Allow-list the URL scheme (https, http, mailto) as well as encoding.
3. Which JavaScript property is safe for inserting plain text into the page?
textContent never parses its value as HTML.
4. How does a nonce-based CSP help against XSS?
An attacker cannot know the fresh random nonce, so their injected script will not run.
Learn Cyber Security with a mentor
Free lessons get you started. Our Ethical Hacking & Cyber Security training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
