Cross-site scripting (XSS) is one of the most widespread and long-lived web vulnerabilities. The mechanism is simple: data supplied by someone (a comment, a username, a URL parameter) ends up in the page and the browser interprets it as code rather than text. From that moment the script runs with your site's privileges: it can read the data displayed, perform actions on the user's behalf and modify the page.
The good news is that the defences are well known and, when applied consistently, very effective.
The three forms of XSS
- Reflected: the data arrives in the request (for example a search parameter) and the server sends it back in the response page without handling it.
- Stored: the data is saved (in a database, in a file) and shown to other users later. It is the most serious, because it affects anyone who visits the page.
- DOM-based: everything happens in the browser. A script reads a value from a controllable source, such as
location.hash, and inserts it into the page unsafely.
The root is always the same: untrusted data placed in a context where it is interpreted as code.
Rule number one: encode output according to context
The central principle is not "clean the input" but encode the output in the right way for the place where it is inserted. The same data needs different treatment depending on the context:
| Context | Example | Treatment |
|---|---|---|
| HTML text | <p>DATA</p> |
HTML-encode <, >, &, ", ' |
| HTML attribute | <input value="DATA"> |
HTML-encode, attribute always quoted |
| URL | <a href="DATA"> |
check the scheme and URL-encode |
| JavaScript | <script>var x = "DATA"</script> |
avoid: pass the data another way |
| CSS | style="color: DATA" |
avoid, or restrict to known values |
In practice, modern template systems automatically encode output in the HTML context. The risk arises when this protection is switched off, usually with functions or filters named something like raw, safe or unescaped. Every use of them must be justified and reviewed.
In the browser: textContent instead of innerHTML
In client-side JavaScript, the choice of API makes all the difference:
const name = new URLSearchParams(location.search).get('name');
// Dangerous: the browser interprets the markup
greeting.innerHTML = 'Hello ' + name;
// Safe: the value is treated as plain text
greeting.textContent = 'Hello ' + name;
The APIs to treat with suspicion when they receive external data are innerHTML, outerHTML, insertAdjacentHTML, document.write and, of course, eval, new Function and setTimeout with a string. To build elements, use createElement, textContent and setAttribute with non-executable attributes.
Modern frameworks also encode whatever you put in templates by default. Their "escape hatches" (properties that insert raw HTML) exist for special cases and should only be used with sanitised content.
Watch out for URLs
A link whose address comes from the user may contain a javascript: scheme, which runs code on click. HTML encoding is not enough: you must check the scheme.
function safeUrl(value) {
try {
const url = new URL(value, location.origin);
return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : '#';
} catch {
return '#';
}
}
link.href = safeUrl(userInput);
Use a list of allowed schemes, not a list of forbidden ones: blocklists are easy to get around.
When you have to accept HTML
Sometimes HTML is the content itself: a rich-text editor, a formatted comment, an imported article. In these cases encoding would make everything unreadable, so you need sanitisation: a component that parses the HTML and keeps only safe elements and attributes, removing scripts, event handlers and dangerous URLs.
- Do not write a sanitiser with regular expressions: HTML has too many variants and edge cases.
- Use a maintained, widely used library, such as DOMPurify in the browser or the equivalent in your server-side language.
- Configure a narrow list of allowed elements suited to the use case.
- Sanitise as close as possible to the point where the HTML is inserted into the page.
Additional defences
The following measures do not replace correct encoding, but they limit the consequences if something slips through:
- A strict Content Security Policy without
'unsafe-inline': it blocks most injected scripts from running. - Cookies with the
HttpOnlyattribute: an injected script cannot read them. - Trusted Types: a mechanism supported by some browsers which, enabled through CSP, prevents plain strings from being passed to dangerous APIs such as
innerHTML, forcing them through explicit checking functions. - Input validation: rejecting badly formatted data (a postcode containing letters, a number containing code) reduces the surface, although on its own it is not enough.
How to look for XSS in your own code
- Search the code for every occurrence of
innerHTML,insertAdjacentHTML,document.write,evaland the template filters that disable encoding. - For each, ask where the data comes from and whether a user can influence it.
- Use static analysis tools and linters with security rules.
- In tests, enter characters such as
<,>and quotes into fields and check that they appear as text on the page.
In short
- XSS comes from untrusted data being interpreted as code in the page.
- Encode output according to context and do not disable template escaping without good reason.
- In the browser prefer
textContentandcreateElementtoinnerHTML. - Check URL schemes against a list of allowed values.
- If you must accept HTML, sanitise it with a reliable library and add a strict CSP.