Safeguard
AppSec

XSS Tutorial: How Cross-Site Scripting Works and How to Stop It

A defender's XSS tutorial covering the three attack types, why they still slip through, and the encoding and CSP controls that actually prevent them.

Aisha Rahman
Security Analyst
6 min read

This XSS tutorial explains cross-site scripting as a defender: XSS is a vulnerability where an application returns untrusted input to the browser in a way that lets it execute as script, and you prevent it by encoding output for its context and locking down what the browser is allowed to run. We will look at how the three variants differ and, more usefully, how to shut each one down. This is not an XSS attack tutorial in the offensive sense — there are no payloads here aimed at real targets, only the concepts you need to defend your own code.

The one idea behind every XSS bug

Every cross-site scripting bug reduces to the same mistake: data crosses into a place where the browser expects code, and nothing told the browser to treat it as text. If a comment field lets a user submit <script> and your page renders it verbatim inside the HTML body, the browser has no way to know that string was data rather than markup you authored. It executes.

That framing matters because it points straight at the fix. You are not trying to detect "bad" input — attackers have endless ways to obfuscate. You are trying to guarantee that whatever the user sends can never leave the data lane.

Reflected XSS

Reflected XSS happens when input from the current request is echoed straight back in the response. A classic example is a search page that prints the query:

Results for: <the raw query goes here>

If the query is placed into the HTML without encoding, a crafted query becomes markup. Reflected XSS requires the victim to follow a malicious link, so it is often delivered through phishing. It is non-persistent — the payload lives only in that one request.

Stored XSS

Stored (or persistent) XSS is worse. Here the untrusted input is saved — a profile bio, a support ticket, a product review — and served to every user who views that record. One submission attacks everyone who loads the page, with no link to click. Stored XSS in an admin-facing view is a common path to full account takeover because the payload runs in a privileged session.

DOM-based XSS

DOM-based XSS never touches the server response body at all. The vulnerability lives in client-side JavaScript that reads from a source it does not control — location.hash, document.referrer, a postMessage — and writes it into a dangerous sink like innerHTML or document.write:

// vulnerable: writes attacker-controllable data into the DOM as HTML
element.innerHTML = decodeURIComponent(location.hash.slice(1));

Because the flaw is in the browser, server-side filtering never sees it. You have to fix it in the client code.

Defense 1: context-aware output encoding

The primary defense is encoding output for the exact context it lands in. The same string needs different treatment inside HTML body, an HTML attribute, a JavaScript string, a URL, or CSS. Encode for HTML and the browser renders the characters as literal text:

function escapeHtml(s) {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

In practice you should not hand-roll this. Modern frameworks encode by default: React escapes values in JSX, Angular sanitizes bindings, and template engines like Jinja2 auto-escape unless you explicitly mark a value safe. The bugs happen when developers reach around the framework — dangerouslySetInnerHTML in React, the | safe filter in Jinja2, bypassSecurityTrustHtml in Angular. Treat every one of those as a security review trigger.

Defense 2: avoid the dangerous sinks

For DOM-based XSS, prefer textContent over innerHTML whenever you are inserting text. If you genuinely need to render user-supplied HTML — a rich-text comment, for example — run it through a vetted sanitizer such as DOMPurify rather than trusting a regex:

element.textContent = userInput; // safe: never parsed as HTML

Defense 3: a real Content Security Policy

A Content Security Policy is defense in depth. Even if a payload slips through, a strict CSP can stop the browser from executing it. A nonce-based policy blocks inline scripts unless they carry the server-generated nonce for that response:

Content-Security-Policy: script-src 'nonce-r4nd0m' 'strict-dynamic'; object-src 'none'; base-uri 'none'

CSP is not a substitute for encoding — it is the seatbelt for when encoding fails. Deploy it in report-only mode first, collect violation reports, then enforce.

Where scanning fits

XSS in your own templates is caught by SAST tools that trace tainted input from source to sink. XSS delivered through a vulnerable front-end library — an outdated jQuery, a sanitizer with a known bypass — is caught by dependency scanning; an SCA tool such as Safeguard flags the vulnerable version transitively. Dynamic testing rounds it out by actually probing the running app. Our DAST product covers that runtime angle, and the Academy has a deeper module on taint analysis.

FAQ

What is the difference between reflected and stored XSS?

Reflected XSS echoes input from the current request back in the immediate response, so it needs the victim to trigger a crafted request (usually via a link). Stored XSS saves the malicious input server-side and serves it to everyone who later views that data, with no per-victim link required. Stored is generally higher impact.

Does using React or Angular make me immune to XSS?

No, but it removes the most common source. These frameworks encode output by default, so straightforward interpolation is safe. You reintroduce XSS the moment you use an escape hatch like dangerouslySetInnerHTML, bind to innerHTML, or render HTML from a source you did not sanitize. DOM-based XSS in your own JavaScript is also still possible.

Is a Content Security Policy enough on its own?

No. CSP is defense in depth, not a primary control. A misconfigured policy (using unsafe-inline, overly broad host allowlists) provides little protection, and even a strict policy is meant to catch failures of your real defense, which is context-aware output encoding. Use both.

How do I test my application for XSS safely?

Use SAST to find unsafe sinks in your source, SCA to find vulnerable front-end dependencies, and a DAST scanner to probe the running application in a non-production environment you own. Never test payloads against systems you do not have authorization to test.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.