Cross-site scripting has been on the OWASP radar for two decades and it is still one of the most reported classes of web vulnerability in 2026. The reason is structural: every application that takes user input and renders it back into a page is one missed encoding away from running attacker JavaScript in another user's browser — inside your own origin, with your own cookies and session.
This guide is the practical, end-to-end version. You will stand up a safe lab, learn to spot injection points by output context (the single most important skill), exploit reflected, stored and DOM-based XSS by hand, then work through filter, sanitizer and Content-Security-Policy bypasses the way an attacker actually does. The last third flips to blue team: how to detect XSS attempts with KQL and SPL, and how to kill the bug class with output encoding, a strict CSP and Trusted Types.
Authorization note: every payload here is scoped to deliberately vulnerable targets you own — DVWA, OWASP Juice Shop, PortSwigger Academy. Testing XSS against systems you do not have written permission to test is a crime in most jurisdictions. Keep it in the lab.
◈ Table of Contents
01 — What XSS Is & Why a Defender Cares
FOUNDATIONCross-site scripting is a code-injection flaw where an application includes untrusted data in a web page without properly neutralising it, so the victim's browser executes attacker-controlled script. The critical word is origin: the script runs as if the application itself wrote it, so it inherits the page's cookies, local storage, DOM and the user's authenticated session. The browser's same-origin policy — the thing that normally stops evil.com reading bank.com — offers no protection, because from the browser's point of view the script is bank.com.
There are three classic classes, split by where the untrusted data enters and where it is executed. Learn the taxonomy first; every payload decision downstream depends on which class you are dealing with.
| Class | Where payload lives | Executes when | Server involved? | Typical sink |
|---|---|---|---|---|
| Reflected | In the request (URL param, form field, header) | Victim follows an attacker-crafted link | Yes — echoed in the immediate response | Server-side template / echo |
| Stored | Saved in the app (DB, profile, comment, log) | Any user views the affected page | Yes — persisted then served | Server-side render of stored value |
| DOM-based | In the request, but never leaves the browser | Client JS reads a source and writes a sink | No — purely client-side | innerHTML, eval, document.write |
A fourth label you will see is blind XSS — a stored payload that fires in a context you cannot see, such as an admin support-ticket viewer or a log-analysis dashboard. It is not a separate mechanism, just stored XSS where the execution happens out of your view, which is why out-of-band callbacks matter (Phase 09). You will also hear self-XSS (the victim must paste the payload into their own console) and mutation XSS / mXSS (the browser's HTML parser rewrites supposedly-safe markup into executable markup) — both covered later.
Session Theft
Exfiltrate non-HttpOnly cookies or bearer tokens from localStorage and ride the victim's session.
Account Takeover
Script the app's own "change email / password" flow using the victim's session to lock them out silently.
Keylogging & Phishing
Attach input listeners or inject a fake login form that posts credentials to an attacker endpoint.
Self-Propagating Worms
Stored XSS on a social feature can replicate itself to every viewer — the Samy worm hit a million MySpace profiles in under a day.
Why should a blue-teamer learn to exploit it? Because detection content you have never tested is detection content you do not trust. Writing the payload teaches you exactly which request shape to hunt for, which response anomaly betrays a successful injection, and which CSP directive would have stopped it. Every offensive step in this guide has a defensive mirror in Phase 10 and 11.
The single best free resource to practise alongside this guide is the PortSwigger Web Security Academy XSS track — free, hosted, and each lab isolates one concept. Keep it open in a second tab and solve the matching lab after each phase here.
02 — Build Your XSS Lab (3 Ways)
SETUPYou need targets you are allowed to break and a proxy to see the raw traffic. The table below is the minimum kit; after it, three install paths — bare metal, Docker, and a zero-install hosted option.
| Component | Purpose | Recommended |
|---|---|---|
| Attacker box | Run the proxy + browser | Kali Linux 2026.x rec |
| Intercepting proxy | See/modify raw requests | Burp Suite Community or OWASP ZAP |
| Vulnerable targets | Legal practice surface | DVWA, OWASP Juice Shop, XSS-game |
| Browser | Render + DevTools | Firefox ESR (easy proxy + cert) |
| Network | Isolation | Host-only / NAT never bridged |
DVWA ships XSS modules at three security levels (Low → Impossible), which is perfect for practising filter bypass. The native install gives you a real PHP app you can tweak at the source to see why a filter fails.
Browse to http://localhost/dvwa/setup.php, click Create / Reset Database, log in with admin / password, then open XSS (Reflected), XSS (Stored) and XSS (DOM). Start at DVWA Security = Low and raise it as you go.
Bind this VM to a host-only or NAT network, never a bridged interface reachable from your LAN. A deliberately vulnerable app is a genuine foothold for anyone who finds it.
The fastest reproducible lab. This docker-compose.yml stands up DVWA, Juice Shop and a tiny reflector you control, each on its own port. Tear it down and rebuild from clean in seconds.
DVWA is on :8081, Juice Shop on :8083, and your callback collector on :9000. Run docker compose down -v to destroy everything including volumes when done.
The collector service lets you prove exfiltration locally — point payloads at http://localhost:9000 and watch the hits land in the container logs with docker compose logs -f collector. No internet, no third party, fully contained.
For CSP-bypass, DOM and cross-origin exfiltration labs, the hosted PortSwigger Academy is unbeatable — free, no install, and each lab's "solve" condition teaches the exact success criterion. Pair it with Burp Suite Community on Kali.
Work the Academy XSS track top to bottom. The reflected/stored/DOM labs back Phases 04–06, and the CSP labs back Phase 08 exactly.
03 — Finding Injection Points by Context
THE KEY SKILLBeginners memorise <script>alert(1)</script> and give up when it is filtered. Professionals ask a different question first: in what context does my input land in the response? The same input needs a completely different payload depending on whether it is reflected into HTML text, an HTML attribute, a <script> block, a URL, or a CSS value. Get the context right and most "filtering" evaporates.
Never start with <script>. Start with a harmless unique string that is unlikely to appear naturally, send it through every parameter, then search the raw response for it. Where and how it is reflected tells you the context — and whether special characters survive.
- Your marker appears in the response body verbatim
- At least one of
< > " 'is reflected without HTML-encoding - The surrounding markup tells you which context you must break out of
Automate the input-surface map with a parameter fuzzer so you do not miss hidden sinks: ffuf -u 'http://localhost:8081/page?FUZZ=zzq1' -w params.txt -mr zzq1 matches only responses that reflect your marker.
Reflected and stored XSS hide in more than URL parameters. Enumerate the whole surface before you commit to one injection point.
| Surface | Examples | Often missed because |
|---|---|---|
| Query / POST | search, name, redirect | obvious — scanners cover it |
| Headers | User-Agent, Referer, X-Forwarded-For | reflected in error/admin pages |
| Cookies | theme, lang, last-page | echoed into the DOM by client JS |
| Path segments | /profile/<name> | rendered in breadcrumbs |
| File content | filename, CSV cell, SVG upload | stored + rendered later (blind) |
| JSON fields | API body values | SPA writes them to innerHTML |
A reflected value inside an href, src, or formaction does not need angle brackets at all — a javascript: URI is enough. Teams that only block < and > leave this wide open.
04 — Reflected XSS
REQUEST → RESPONSEReflected XSS is the simplest to understand: the app takes a value from the request and writes it straight into the response. The payload is never stored, so delivery is the hard part — you need the victim to send your request, usually by clicking a crafted link. It is still dangerous because a single link in a phishing email executes in the victim's authenticated session.
Against DVWA's reflected module at Low, your marker lands in raw HTML text, so a bare injection works. Raise the level and the context (or a filter) changes, so you switch payloads. These are the canonical one-liners per context.
Use document.domain rather than 1 in the alert — it proves which origin executed the script, which matters when you are chasing a reflection that renders inside an iframe or a different subdomain.
Reflected XSS is only useful if the victim sends the request. For a GET sink that is a URL; for a POST sink it is an auto-submitting form hosted on a page you control. In the lab, your collector service plays the victim-landing role.
Browsers ship anti-XSS heuristics and aggressive URL encoding, but they are not a control you can rely on — Chrome removed its XSS Auditor years ago. Never assume the browser will save a user from a reflected payload.
05 — Stored XSS
HIGHEST IMPACTStored (persistent) XSS is the dangerous one. The payload is saved server-side — a comment, a profile field, a product review, a support ticket — and then served to every user who views that page, with no crafted link required. One successful injection can hit thousands of sessions, and if it lands in an admin-only view it is effectively a privilege-escalation primitive.
DVWA's stored-XSS module is a guestbook that saves name + message. At Low it stores and renders raw HTML. Watch for a maxlength attribute on the input — it is client-side only, so remove it in DevTools or send the request directly through Burp to use a longer payload.
Now load the guestbook in a browser session and watch the hit arrive in your collector: docker compose logs -f collector. Every visitor who renders that page sends their cookie — that is the whole point of stored XSS.
For blind stored XSS (admin ticket viewers, log dashboards) you will not see the execution. Plant a callback payload and wait — if your collector logs a hit minutes later from a different IP/User-Agent, you have proven execution in a context you cannot even see. Tools like XSS Hunter formalise this pattern.
An SVG is XML that browsers execute as markup — including <script> inside it. If an app stores an uploaded SVG and serves it from its own origin (an avatar, a document preview), opening that file is stored XSS. The filename itself is another stored sink if it is echoed into a listing.
This only fires if the file is served with an HTML/SVG Content-Type from the app's own origin. Serving user uploads from a separate sandbox domain with Content-Disposition: attachment is the fix — see Phase 11.
06 — DOM-Based XSS
CLIENT-SIDE ONLYDOM-based XSS never touches the server-side render. Client JavaScript reads attacker-controllable data from a source (like location.hash) and passes it to a dangerous sink (like innerHTML or eval) without sanitisation. Because the payload can live entirely in the fragment (#) part of a URL, it is frequently not even sent to the server — which is exactly why server-side WAFs and logs miss it.
Finding DOM XSS is a code-reading exercise. Open DevTools, search the loaded JS for sinks, then trace backwards to see whether any of them is fed by a source you control.
| Sources (attacker-influenced) | Sinks (execute / inject) |
|---|---|
| location.hash / .search / .href | element.innerHTML / outerHTML |
| document.referrer | document.write() |
| window.name | eval() / setTimeout(str) |
| postMessage event.data | element.setAttribute('href', …) |
| localStorage / cookies | jQuery $(…), $.html() |
Classic vulnerable pattern: a page reads location.hash and drops it into innerHTML to render a "tab" or search term. Because innerHTML will not run a bare <script> inserted after load, you use an auto-firing event handler instead.
innerHTML assignment does not execute <script> elements, so beginners think they are safe. They are not — <img onerror>, <svg onload> and <iframe srcdoc> all fire from an innerHTML write. Test with an event handler, never a script tag.
07 — Filter & Sanitizer Bypass
EVASIONReal apps rarely reflect input raw. They strip <script>, blocklist keywords, or run the value through an HTML sanitizer. Bypass work is the difference between "it is filtered" and "it is exploitable." The mindset is always the same: understand exactly what the filter does, then find the input it fails to normalise.
A filter that deletes the literal string script once, or only matches lowercase, or only blocks <script> but not other tags, is defeated by case, nesting, or simply using a different tag. There are hundreds of tags/handlers that execute without the word "script."
When you are not sure what survives a filter, feed it a known payload corpus and watch what comes back. The PortSwigger XSS cheat sheet's "copy events/tags" feature and the payloadbox/xss-payload-list repo are the go-to sources — then trim to what your specific filter lets through.
Filters often inspect the decoded-once value while the browser decodes again in a different layer. HTML entities, URL double-encoding, and JS escapes let the payload slip past the inspector but reassemble in the browser.
Double-URL-encoding (%253Cscript%253E) works when one tier of the stack decodes before the filter runs and another decodes after. Always test single and double encoding against proxies and WAFs.
Client-side sanitizers like DOMPurify are strong, but mXSS attacks exploit the gap between what the sanitizer parses and what the browser re-parses after insertion. Markup that looks inert to the sanitizer can mutate into executable markup when the browser normalises it — historically via <template>, <noscript>, namespace confusion (SVG/MathML) and HTML-in-foreign-content quirks.
mXSS vectors are highly version-specific — a payload that beats one DOMPurify release is patched in the next. Do not rely on a sanitizer version as a boundary; always pair it with a strict CSP. Keep DOMPurify pinned to the latest release.
Defensive takeaway: because sanitizers are a moving target, treat them as defence-in-depth, not the control. The durable fix is context-aware output encoding plus Trusted Types (Phase 11), which makes the dangerous sink itself refuse a raw string.
08 — CSP Bypass Techniques
DEFENCE-IN-DEPTH, DEFEATEDContent Security Policy is a browser-enforced allowlist of where scripts may come from. A good CSP turns many XSS bugs into non-events. But CSP is widely mis-configured, and a weak policy gives a false sense of safety. Knowing how policies fall over tells you both how to exploit a weak one and how to write a strong one in Phase 11.
Every bypass starts with reading the Content-Security-Policy header. Grab it, then check it against a known-weakness checklist.
| Weakness in policy | Why it is exploitable |
|---|---|
| 'unsafe-inline' | inline handlers/scripts allowed — CSP barely helps |
| 'unsafe-eval' | string-to-code sinks (eval) still run |
| script-src * | any host allowed — load from attacker CDN |
| allowlisted CDN w/ JSONP | abuse a JSONP endpoint on the allowed host |
| no object-src / base-uri | base-tag hijack & plugin vectors |
If the policy trusts a whole domain that happens to host a JSONP endpoint, you can run arbitrary JS from that trusted host — the classic allowlist bypass. If base-uri is unset, a <base> tag can repoint relative script loads to your server.
Nonce-based CSP is strong only if the nonce is unpredictable and never leaked. Two real failure modes: a nonce that is reused across responses (so you can hardcode it), and strict-dynamic, where any script already trusted can create new scripts freely — so one injection into a trusted script's dataflow propagates.
Nonce exfiltration via CSS injection and dangling markup is an active 2026 technique. A nonce that appears in markup an attacker can partially control is not a secret. Regenerate per response and never place attacker-reachable content before a nonced script.
09 — Weaponization & Impact
WHY IT MATTERSalert(1) proves execution; it does not prove impact. To write a report a business will act on — and to understand what you are defending against — you translate execution into concrete harm. Everything here is lab-only and points at your local collector, never a live victim.
The default demonstration of impact: steal the session. This works only against cookies without the HttpOnly flag, or against tokens stored in localStorage (which is always script-readable — a reason to prefer HttpOnly cookies over JWT-in-localStorage).
HttpOnly blocks document.cookie from reading the session cookie — but XSS can still use it by making authenticated requests as the victim. HttpOnly limits theft, not abuse.
More reliable than cookie theft: do not steal the session, use it. Script the application's own account-management flow — fetch the CSRF token from the page, then submit an email change — all inside the victim's authenticated context. This defeats HttpOnly entirely.
In a report, an ATO proof-of-concept moves XSS from "medium" to "critical" far more convincingly than a cookie dump. It demonstrates the bug survives HttpOnly, SameSite and token-based auth in one shot.
XSS gives you the whole DOM, so you can attach input listeners, inject a convincing fake login prompt, or hook the browser into a control framework. BeEF (the Browser Exploitation Framework, pre-installed on Kali) is the standard lab tool for demonstrating sustained post-exploitation of a hooked browser.
BeEF hooks and keyloggers are for your own lab browsers only. Running them against anyone who has not consented is illegal regardless of intent — keep the hook pointed at your own VM.
10 — Detection & Threat Hunting
BLUE TEAMYou cannot patch every XSS instantly, so you also hunt for exploitation attempts and successful injections in your logs. The detections below assume you are shipping web/WAF logs to Microsoft Sentinel and to Splunk. Each query is preceded by what it finds; every KQL block has a paired SPL equivalent.
The highest-signal hunt is for XSS markup in URL and body fields of inbound requests. Decode first (attackers URL-encode), then match on tag/handler signatures. Expect false positives from WAF test traffic and security scanners — tune by source IP.
A CSP in report-to/report-uri mode turns the victim's browser into a sensor: a blocked inline script generates a violation report. A spike of script-src violations on one page is a strong signal of a live XSS attempt that your policy just blocked.
Deploy CSP in report-only mode before enforcing it. You get the sensor value immediately and a complete inventory of what you would break, with zero risk to users — then flip to enforcing once the reports are clean.
Successful XSS usually calls home. Hunt egress/proxy logs for beacons to newly-seen external hosts whose requests carry cookie-shaped or base64 query strings, correlated with a Referer from your own app.
XSS spans Initial Access through Collection. Tag your analytics rules with the techniques below so coverage is visible on the ATT&CK Navigator.
| Technique | ID | Where it fits in XSS |
|---|---|---|
| Drive-by Compromise | T1189 | Victim loads a page carrying injected script |
| Exploit Public-Facing Application | T1190 | Injecting the stored/reflected payload |
| Steal Web Session Cookie | T1539 | Cookie/token exfiltration via script |
| Browser Session Hijacking | T1185 | Acting as the victim in-session (ATO) |
| Exfiltration Over Web Service | T1567 | Beacon of stolen data to attacker host |
11 — Prevention & Hardening
KILL THE BUG CLASSDetection buys time; prevention ends the problem. XSS is one of the most solvable vulnerability classes because the fixes are well understood and layer cleanly. Apply them in order of durability: context-aware output encoding first, then framework defaults, then CSP and Trusted Types as defence-in-depth.
Encode untrusted data for the exact context it lands in — HTML body, attribute, JS, URL and CSS each need different encoding. Modern frameworks do this by default; the vulnerabilities appear when developers opt out.
The three functions that reintroduce XSS in otherwise-safe frameworks: React's dangerouslySetInnerHTML, Angular's bypassSecurityTrust*, and Vue's v-html. Grep your codebase for them in review — each one is a sink that needs a sanitizer in front of it.
A strict CSP neutralises most injected script even when an encoding bug slips through. The modern recommendation is a nonce-based policy with strict-dynamic, plus Trusted Types to lock down DOM sinks so a raw string simply cannot reach innerHTML.
Roll CSP out in three stages: (1) Content-Security-Policy-Report-Only to collect violations, (2) fix what it flags, (3) enforce. Trusted Types supports the same report-only mode — use it to find every unsafe DOM write before you enforce.
Round out the defence with the flags and isolation that limit blast radius, then verify the whole stack holds under your Phase 04–08 payloads.
- Every reflected/stored value is context-encoded by the framework, no manual escape hatches with raw input
- Strict nonce-based CSP enforced, reports clean,
object-srcandbase-urilocked to'none' - Trusted Types enforced; no raw-string writes reach
innerHTML/eval - Session cookies are
HttpOnly; Secure; SameSite; tokens are not inlocalStorage - User uploads served from a sandbox origin with
nosniff+ attachment disposition - Your Phase 04–08 lab payloads all fail against the hardened app
Do not treat a WAF as the fix. WAFs reduce noise and buy time, but every bypass in Phase 07–08 is, at root, a WAF/filter bypass. Encoding + CSP + Trusted Types is the control; the WAF is a speed bump.
◈ Troubleshooting — Common XSS Testing Problems
| Symptom | Likely cause | Fix |
|---|---|---|
| Payload shows as text, not executing | Output is HTML-encoded (not vulnerable here) or wrong context | Re-check the context; try attribute/JS breakout; confirm chars survive un-encoded |
| <script> in innerHTML won't run | innerHTML never executes injected script elements | Use <img onerror> / <svg onload> event handlers |
| Payload truncated | Client-side maxlength or server length cap | Send via Burp/curl to bypass client limits; shorten with <svg onload> |
| Works in curl, not in browser | Encoding mismatch or the sink is DOM-based | URL-encode for the browser; check client JS source→sink |
| Blocked by CSP in console | Policy forbids inline/your source | Read the header; try allowlist/JSONP/nonce vectors (Phase 08) |
| Stored payload never fires | Blind context or value rendered elsewhere | Use an out-of-band callback collector and wait |
12 — Sources & References
VERIFYPrimary references for the techniques and defences in this guide. Practise the offensive steps only against the deliberately vulnerable targets in Phase 02 or systems you are explicitly authorised to test.
⚖️ Authorized testing only.
Every payload in this guide is scoped to deliberately vulnerable labs (DVWA, OWASP Juice Shop, PortSwigger Academy) or systems you own and are authorised to test. Unauthorised XSS testing against third-party applications is illegal in most jurisdictions. Build the lab, learn the tradecraft, then use it to harden your own apps and write detections you can trust.
Need a managed web-app assessment or detection-engineering support? CyberHawk Threat Intel runs authorized testing and builds the Sentinel/Splunk content to catch what it finds.
◈ Stay Connected
Follow CyberHawk Threat Intel for threat intelligence, deployment guides and hands-on SOC tooling content.
"They can't exploit you if you are the Exploit."