Cross-site scripting (XSS): examples, fixes and a test checklist
Cross-site scripting (XSS) is a flaw where an application puts untrusted data into a page without the right encoding for that spot, so the browser runs the attacker's script in your site's origin. It comes in reflected, stored and DOM-based forms. Fix it with context-aware output encoding, safe DOM APIs, a maintained sanitizer for user HTML, and a Content Security Policy as a second layer.
Cross-site scripting (XSS) is a flaw where a web application puts untrusted data into a page without the encoding that spot needs. The browser then treats that data as code and runs it with the privileges of your site. CWE-79 names the weakness "Improper Neutralization of Input During Web Page Generation". OWASP's Top 10:2025 files XSS under A05 Injection.
This page covers the three types, a worked example, the encoding rule for each output context, fixes for several stacks, defense layers, and how testers prove a finding. Test only systems you own or have written authorization to test.
What is cross-site scripting?#
XSS lets a script that you did not write run inside your page. The script runs in your origin, so the browser gives it the same access as your own code. It can read what the page shows, call your APIs with the user's session and change the page.
The root cause is simple. An application builds HTML, JavaScript or a URL by joining strings, and one of the strings came from a user. OWASP's cheat sheet says coverage is what matters: an application that protects 99 places and misses one is still vulnerable.
How does an XSS attack work?#
The attacker gets a value into a page that another person views, and the page prints it without encoding. The example below uses a fictional app.
Demonstration only: Harbor Notes, a fictional app. No real customer system was tested.
A search page on https://notes.example.com/search prints the query back to the user.
Vulnerable server code (Node.js, Express, string concatenation):
app.get('/search', (req, res) => {
res.send('<h1>Results for ' + req.query.q + '</h1>');
});
Request with a harmless proof string:
GET /search?q=<img src=x onerror=alert(document.domain)> HTTP/1.1
Host: notes.example.com
Response body:
<h1>Results for <img src=x onerror=alert(document.domain)></h1>
The image fails to load, so the browser runs the error handler. The dialog shows notes.example.com, which proves the code ran in the app's origin.
The fix is to treat the query as text. A template engine that escapes by default turns < into <, and the browser then prints the characters instead of running them.
What are the three types of XSS?#
XSS has three types, and they differ in where the untrusted data travels before it runs. PortSwigger uses the same three groups.
| Type | Where the data comes from | Where it becomes code | Victim must |
|---|---|---|---|
| Reflected | The current request (query, form field, header) | Server response to that request | Open a crafted link or submit a crafted form |
| Stored | Saved earlier in a database, file or log | Server response to any later page view | View the page that shows the saved data |
| DOM-based | A browser source such as location.hash |
Client-side JavaScript writes it into the page | Open a page whose script reads the source |
OWASP defines stored attacks as those where the injected script is permanently stored on the target server, for example in a database, forum, visitor log or comment field. Stored XSS usually has the highest impact because it needs no crafted link. Read the full guide: stored XSS. In DOM-based XSS the server response may be clean, and the flaw sits in the page's own JavaScript. Read the full guide: DOM-based XSS.
What can an attacker do with XSS?#
Injected script acts as the logged-in user inside your site. The usual outcomes are these:
- Perform actions as the user, such as changing an email address or posting content.
- Read data the page can read, including CSRF tokens, personal data and API responses.
- Change what the user sees, for example by adding a fake login form.
- Take a session token that scripts can read (cookies without HttpOnly, tokens in
localStorage).
The impact scales with the viewer. Script that runs in an administrator's session reaches administrator functions. Testers rate that case higher than the same flaw seen only by the author.
Which output encoding fits which context?#
Use the encoder that matches the place where the data lands. HTML encoding does not protect a JavaScript string, and JavaScript encoding does not protect a URL. OWASP's cheat sheet makes this point and warns that a servlet filter cannot know the output context.
| Output context | Example | Safe approach |
|---|---|---|
| HTML body | <p>NAME</p> |
HTML-encode, or set textContent |
| Quoted HTML attribute | <input value="NAME"> |
Attribute-encode and always quote the value |
| JavaScript string | var n = 'NAME'; |
Avoid it. Pass data as JSON in a data- attribute or a JSON script block |
URL in href or src |
<a href="URL"> |
Allow only https: and http: schemes, then encode |
| CSS value | A color in a style attribute | Use a fixed list of allowed values |
| Raw HTML from users | Rich text editor | Sanitize with a maintained library, on the server or at render time |
How do you fix XSS in each stack?#
Start with the framework's default escaping and avoid the escape hatches. The table shows each default and the call that turns it off.
| Stack | Default | Escape hatch to avoid |
|---|---|---|
| React | Escapes values in JSX | dangerouslySetInnerHTML |
| Angular | Treats values as untrusted and sanitizes them | bypassSecurityTrust* methods |
| Vue | Escapes text interpolation | v-html |
| Django | Auto-escapes template variables | |safe, mark_safe, {% autoescape off %} |
Go html/template |
Contextual auto-escaping | template.HTML and similar types |
| ASP.NET Core Razor | Encodes @value |
Html.Raw |
React's docs say that untrusted HTML passed to dangerouslySetInnerHTML risks an XSS vulnerability. Angular's docs say Angular treats all values as untrusted by default. Django escapes template variables automatically, and Go's html/template escapes by context. Check the Vue and Razor rows in each project's own docs before you rely on them.
Express with EJS (use <%= %>, not <%- %>):
// views/search.ejs
// <h1>Results for <%= q %></h1> escaped
// <h1>Results for <%- q %></h1> raw: do not use for user data
app.get('/search', (req, res) => res.render('search', { q: req.query.q }));
Python with Flask and Jinja2. Autoescape is on for .html templates, so print the variable with the normal double-curly output tag. Avoid the safe filter and Markup() on user data. When you build a string in Python, escape it yourself:
from markupsafe import escape
@app.get("/search")
def search():
q = request.args.get("q", "")
return "<h1>Results for " + str(escape(q)) + "</h1>"
PHP without a template engine (htmlspecialchars):
<h1>Results for <?= htmlspecialchars($_GET['q'] ?? '', ENT_QUOTES, 'UTF-8') ?></h1>
Java with Spring and Thymeleaf (th:text escapes, th:utext does not):
<h1>Results for <span th:text="${q}"></span></h1>
For client-side code, write text with textContent and not innerHTML. The page on DOM-based XSS covers sinks in detail.
Which defense layers help when a bug slips through?#
Encoding is the fix. These layers reduce the damage when a flaw remains, and none of them replaces it.
| Layer | What it does | Limit |
|---|---|---|
| Content Security Policy | Tells the browser which scripts may run, so injected inline script is blocked | A policy that allows unsafe-inline adds little. See Content Security Policy |
| HttpOnly cookie flag | Hides the cookie from scripts | Injected script still acts as the user from inside the page |
| Secure and SameSite flags | Limit where the cookie is sent | Do not stop script that already runs on your site |
| Trusted Types | Makes the browser reject plain strings in dangerous DOM sinks | Covers DOM sinks only |
| Sanitizer for user HTML | Removes active content from HTML you must accept | Needs updates. See CVE-2020-26870 below |
| WAF | Blocks known bad request patterns | Does not know your output context and does not see DOM-based flaws |
MDN documents the cookie attributes, and its CSP guide documents the policy directives. A starting policy for a site with no inline scripts:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'
What real XSS incidents and CVEs exist?#
Real cases show how a small gap grows. Each row links to its source.
| Date | Case | Root cause and lesson |
|---|---|---|
| October 2005 | The Samy worm on MySpace. By its author's account it reached more than 1,000,000 users in under 20 hours (source) | The site blocked some tags and words, and the author found markup that the filter missed (technical write-up). A blocklist is not output encoding. |
| 14 March 2019 | CVE-2019-9787, WordPress before 5.1.1 | NVD says mishandled CSRF protection and incorrect processing of link elements in comments led to XSS and then administrator access. |
| 29 April 2020 | CVE-2020-11022, jQuery 1.12.0 up to 3.5.0 | Passing HTML from untrusted sources to methods such as .html() could run code, even after sanitizing. Fixed in jQuery 3.5.0. |
| 7 October 2020 | CVE-2020-26870, DOMPurify before 2.0.17 | A mutation XSS case: the serialize-parse round trip did not return the original tree. Keep sanitizers updated. |
How do penetration testers find and prove XSS?#
Testers prove XSS with script that runs in a real browser, in the victim origin, with evidence. A reflected string alone is not a finding. Our method for web application penetration testing follows these steps:
- Find each place where input is reflected, stored or read by client script. Include URL parts, headers, JSON fields, file names and API responses that a front end renders.
- Send a unique, harmless marker and find where it appears in the response and in the rendered DOM.
- Record the output context for each hit: HTML body, attribute, JavaScript, URL or CSS.
- Send a minimal context-specific proof, such as
alert(document.domain)orprint(), and watch it in a real browser. - Run controls: the same request with the marker only, a second account, and a signed-out session. A control shows whether the effect depends on the payload and not on an error page.
- For stored cases, load the page from a second session to show the data was stored in one request and ran in another.
- Record the request, the response excerpt, the browser name and version, the context and the cookie flags.
A finding counts only when the script runs in the victim origin. The report labels it "Browser-observed" and lists what was not tested. The same method covers APIs that return HTML fragments. See API penetration testing.
Controls that held go in the report too. If the app encoded the marker in the HTML body but not in an attribute, the report says so, so the fix does not break the encoding that works.
Cross-site scripting testing checklist#
Use this list in a test plan. Each item maps to a finding or to a control that held.
- List every input: query, path, body, headers, cookies, file names, WebSocket messages, imported data.
- Search every view that renders that input, including admin pages, emails, exports and logs.
- Check the output context for each hit and compare it to the encoding table above.
- Check client code for sinks (
innerHTML,document.write,eval,hrefassignment). - Check each framework escape hatch in the source:
dangerouslySetInnerHTML,v-html,|safe,Html.Raw. - Check user HTML paths: which sanitizer, which version, which configuration.
- Read the CSP: does it allow
unsafe-inline, wildcard hosts or data URLs for scripts? - Check cookie flags and where tokens live (cookie or
localStorage). - Check that responses declare the right
Content-TypeandX-Content-Type-Options: nosniff. - Retest after the fix with the same request in a browser, and keep the controls.
XSS vs CSRF: what is the difference?#
XSS runs attacker script inside your site. CSRF makes the user's browser send a request that the user did not intend. WSTG-SESS-05 describes CSRF as forcing an end user to execute unintended actions in an application where they are authenticated.
| XSS | CSRF | |
|---|---|---|
| Attacker code runs in your origin | Yes | No |
| Can read responses | Yes | No |
| Stopped by CSRF tokens | No, script can read the token | Yes |
| Stopped by output encoding | Yes | No |
Fix both. A site with an XSS flaw cannot rely on CSRF tokens.
When should you get a professional XSS test?#
Get a professional test when your app renders user content, has several roles, or loads a lot of client-side JavaScript. Scanners find simple reflected cases. Judging context, roles and second-order storage takes a person who reads the browser result. Excession Labs tests XSS as part of web application penetration testing and API penetration testing, with written authorization, browser-observed evidence and fix guidance for each finding. Request a web application test from the service page.
Questions
What is the difference between XSS and CSRF?
XSS runs the attacker's script inside your site, so it can read pages and act as the user. CSRF makes the victim's browser send a request the user did not intend, without reading the response. XSS can defeat CSRF tokens because the script can read them. CSRF cannot do the reverse.
Where can I find an XSS cheat sheet?
PortSwigger publishes a maintained XSS cheat sheet, and OWASP publishes the XSS Prevention Cheat Sheet for defenders. Use them on systems you are authorized to test. A payload list does not replace understanding the output context, because the same string is safe in one place and live in another.
How do I prevent cross-site scripting in PHP?
Escape every dynamic value on output with htmlspecialchars() using ENT_QUOTES and UTF-8, or use a template engine such as Twig or Blade that escapes by default. Never echo request data raw. For user-authored HTML, run a maintained sanitizer such as HTML Purifier before you store or show it.
Does an HttpOnly cookie stop XSS?
No. HttpOnly stops scripts from reading the cookie value, which blocks one form of session theft. Injected script can still send requests as the user from inside the page, change the page and read other data. Treat HttpOnly as damage limiting, not as a fix.
Is input validation enough to stop XSS?
No. Validation limits what you accept, but a valid name such as O'Brien is still dangerous in the wrong output context. OWASP says input validation does not replace output encoding and safe APIs. Encode at the point where data enters HTML, an attribute, JavaScript or a URL.
Sources
- OWASP Community: Cross Site Scripting (XSS) (checked 9 November 2026)
- OWASP Cheat Sheet Series: Cross Site Scripting Prevention (checked 9 November 2026)
- CWE-79: Improper Neutralization of Input During Web Page Generation (checked 9 November 2026)
- PortSwigger Web Security Academy: Cross-site scripting (checked 9 November 2026)
- OWASP Top 10:2025 A05 Injection (checked 9 November 2026)
- OWASP WSTG-SESS-05: Testing for Cross Site Request Forgery (checked 9 November 2026)
- MDN: Content Security Policy (CSP) (checked 9 November 2026)
- MDN: Set-Cookie header (HttpOnly, Secure, SameSite) (checked 9 November 2026)
- React docs: Common components (dangerouslySetInnerHTML) (checked 9 November 2026)
- Angular docs: Security (checked 9 November 2026)
- Django docs: Automatic HTML escaping (checked 9 November 2026)
- Go package html/template (checked 9 November 2026)
- PHP manual: htmlspecialchars (checked 9 November 2026)
- Samy Kamkar: The MySpace worm (checked 9 November 2026)
- NVD: CVE-2020-11022 (jQuery before 3.5.0) (checked 9 November 2026)
- NVD: CVE-2020-26870 (DOMPurify before 2.0.17) (checked 9 November 2026)
- NVD: CVE-2019-9787 (WordPress before 5.1.1) (checked 9 November 2026)