# Cross-site scripting (XSS): examples, fixes and a test checklist

Source: https://penetrationtestservices.com/vulnerabilities/cross-site-scripting/
Publisher: Excession Labs (penetrationtestservices.com)
Last updated: 2026-11-09

Cross-site scripting explained: the three types, a worked example, an output encoding table, fixes for six stacks, CSP and cookie flags, and a tester's checklist.

> **Short answer:** 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](https://cwe.mitre.org/data/definitions/79.html) names the weakness "Improper Neutralization of Input During Web Page Generation". [OWASP's Top 10:2025](https://owasp.org/Top10/2025/A05_2025-Injection/) 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](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html) 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):
> 
> ```js
> app.get('/search', (req, res) => {
>   res.send('Results for ' + req.query.q + '');
> });
> ```
> 
> Request with a harmless proof string:
> 
> ```http
> GET /search?q= HTTP/1.1
> Host: notes.example.com
> ```
> 
> Response body:
> 
> ```html
> Results for 
> ```
> 
> 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 `NAME` | HTML-encode, or set `textContent` |
| Quoted HTML attribute | `` | 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` | `` | 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](https://react.dev/reference/react-dom/components/common) say that untrusted HTML passed to `dangerouslySetInnerHTML` risks an XSS vulnerability. [Angular's docs](https://angular.dev/best-practices/security) say Angular treats all values as untrusted by default. [Django](https://docs.djangoproject.com/en/5.1/ref/templates/language/) escapes template variables automatically, and [Go's html/template](https://pkg.go.dev/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 ``):

```js
// views/search.ejs
// Results for    escaped
// Results for    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:

```python
from markupsafe import escape

@app.get("/search")
def search():
    q = request.args.get("q", "")
    return "Results for " + str(escape(q)) + ""
```

PHP without a template engine ([htmlspecialchars](https://www.php.net/manual/en/function.htmlspecialchars.php)):

```php
Results for 
```

Java with Spring and Thymeleaf (`th:text` escapes, `th:utext` does not):

```html
Results for 
```

For client-side code, write text with `textContent` and not `innerHTML`. The page on [DOM-based XSS](/vulnerabilities/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](/vulnerabilities/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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie) documents the cookie attributes, and its [CSP guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) documents the policy directives. A starting policy for a site with no inline scripts:

```http
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](https://samy.pl/myspace/)) | The site blocked some tags and words, and the author found markup that the filter missed ([technical write-up](https://samy.pl/myspace/tech.html)). A blocklist is not output encoding. |
| 14 March 2019 | [CVE-2019-9787](https://nvd.nist.gov/vuln/detail/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](https://nvd.nist.gov/vuln/detail/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](https://nvd.nist.gov/vuln/detail/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](/web-application-penetration-testing/) follows these steps:

1. 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.
2. Send a unique, harmless marker and find where it appears in the response and in the rendered DOM.
3. Record the output context for each hit: HTML body, attribute, JavaScript, URL or CSS.
4. Send a minimal context-specific proof, such as `alert(document.domain)` or `print()`, and watch it in a real browser.
5. 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.
6. For stored cases, load the page from a second session to show the data was stored in one request and ran in another.
7. 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](/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.

1. List every input: query, path, body, headers, cookies, file names, WebSocket messages, imported data.
2. Search every view that renders that input, including admin pages, emails, exports and logs.
3. Check the output context for each hit and compare it to the encoding table above.
4. Check client code for sinks (`innerHTML`, `document.write`, `eval`, `href` assignment).
5. Check each framework escape hatch in the source: `dangerouslySetInnerHTML`, `v-html`, `|safe`, `Html.Raw`.
6. Check user HTML paths: which sanitizer, which version, which configuration.
7. Read the CSP: does it allow `unsafe-inline`, wildcard hosts or data URLs for scripts?
8. Check cookie flags and where tokens live (cookie or `localStorage`).
9. Check that responses declare the right `Content-Type` and `X-Content-Type-Options: nosniff`.
10. 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](https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/06-Session_Management_Testing/05-Testing_for_Cross_Site_Request_Forgery/) 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](/web-application-penetration-testing/) and [API penetration testing](/api-penetration-testing/), with written authorization, browser-observed evidence and fix guidance for each finding. [Request a web application test](/web-application-penetration-testing/) 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)](https://owasp.org/www-community/attacks/xss/)
- [OWASP Cheat Sheet Series: Cross Site Scripting Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html)
- [CWE-79: Improper Neutralization of Input During Web Page Generation](https://cwe.mitre.org/data/definitions/79.html)
- [PortSwigger Web Security Academy: Cross-site scripting](https://portswigger.net/web-security/cross-site-scripting)
- [OWASP Top 10:2025 A05 Injection](https://owasp.org/Top10/2025/A05_2025-Injection/)
- [OWASP WSTG-SESS-05: Testing for Cross Site Request Forgery](https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/06-Session_Management_Testing/05-Testing_for_Cross_Site_Request_Forgery/)
- [MDN: Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP)
- [MDN: Set-Cookie header (HttpOnly, Secure, SameSite)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie)
- [React docs: Common components (dangerouslySetInnerHTML)](https://react.dev/reference/react-dom/components/common)
- [Angular docs: Security](https://angular.dev/best-practices/security)
- [Django docs: Automatic HTML escaping](https://docs.djangoproject.com/en/5.1/ref/templates/language/)
- [Go package html/template](https://pkg.go.dev/html/template)
- [PHP manual: htmlspecialchars](https://www.php.net/manual/en/function.htmlspecialchars.php)
- [Samy Kamkar: The MySpace worm](https://samy.pl/myspace/)
- [NVD: CVE-2020-11022 (jQuery before 3.5.0)](https://nvd.nist.gov/vuln/detail/CVE-2020-11022)
- [NVD: CVE-2020-26870 (DOMPurify before 2.0.17)](https://nvd.nist.gov/vuln/detail/CVE-2020-26870)
- [NVD: CVE-2019-9787 (WordPress before 5.1.1)](https://nvd.nist.gov/vuln/detail/CVE-2019-9787)
