Cross-site scripting is still one of the easiest ways to turn a tiny frontend mistake into a full account takeover. I’ve seen teams spend weeks hardening auth flows while leaving a innerHTML assignment sitting in a comment widget like a loaded gun.

The tricky part is that “XSS payloads” are not all the same. Some rely on raw <script> injection. Some abuse HTML attributes. Some hide inside JavaScript URLs, SVG, Markdown, or template rendering. If you only block one shape, the others get through.

Here’s a practical comparison of the common payload styles developers run into, plus the tradeoffs of blocking each one.

1. Raw <script> tag injection

The classic payload:

<script>alert(1)</script>

Why it works

This lands when untrusted input is inserted into HTML without escaping:

output.innerHTML = userInput;

If userInput contains HTML, the browser parses it as markup instead of text.

Pros for attackers

  • Very simple
  • Easy to test
  • Works immediately in badly written templates or DOM updates

Cons for attackers

  • Many modern filters specifically look for <script>
  • Some template engines escape this by default
  • Easy to spot during code review

Best defenses

1. Output encode by context

If you want text, render text:

output.textContent = userInput;

Server-side templates should HTML-escape by default:

<p>{{ user.bio }}</p>

Not:

<p>{{{ user.bio }}}</p>

if triple braces disable escaping in your templating engine.

2. Avoid unsafe DOM sinks

Bad:

element.innerHTML = data;
document.write(data);

Safer:

element.textContent = data;
element.setAttribute("title", data);

3. Use CSP as backup

A strong Content Security Policy can stop inline script execution even if markup injection happens. For implementation details, see https://csp-guide.com.

Blocking verdict

Best blocked by: context-aware output encoding
Backup control: CSP
Common mistake: trying to strip <script> with regex

Regex filtering is brittle. Attackers just switch payload styles.


2. Event handler payloads

Typical examples:

<img src=x onerror=alert(1)>
<div onclick="alert(1)">click me</div>
<body onload=alert(1)>

Why it works

Browsers treat event handler attributes as executable JavaScript. So even if you block <script>, HTML injection can still execute code.

Pros for attackers

  • Bypasses naive “no script tags allowed” filters
  • Short and reliable
  • Works in many HTML injection cases

Cons for attackers

  • Requires an element and event that actually fires
  • Better sanitizers remove on* attributes
  • Strong CSP can block inline handlers

Best defenses

1. Sanitize untrusted HTML

If you must allow user HTML, use a real sanitizer and remove dangerous attributes and elements.

A sanitizer policy should strip:

  • onerror
  • onclick
  • all on* event handlers
  • dangerous elements like script, iframe, object unless explicitly needed

Example using the upcoming HTML Sanitizer API where available:

const sanitizer = new Sanitizer({
  allowElements: ["b", "i", "p", "a"],
  allowAttributes: {
    "a": ["href"]
  }
});

const clean = sanitizer.sanitizeFor("div", dirtyHtml);
container.replaceChildren(clean);

If your stack uses a third-party sanitizer, configure it aggressively. Default configs are sometimes looser than teams expect.

2. Don’t inject user HTML unless you absolutely need to

A lot of “rich text” requirements are fake requirements. If plain text works, use plain text.

3. Use CSP without unsafe-inline

A policy like this is far better than one that allows inline code:

Content-Security-Policy: script-src 'self' 'nonce-rAnd0m'; object-src 'none'; base-uri 'none';

Blocking verdict

Best blocked by: HTML sanitization
Backup control: CSP blocking inline handlers
Common mistake: removing <script> but allowing arbitrary attributes


3. JavaScript URLs

Common payloads:

<a href="javascript:alert(1)">click</a>
<iframe src="javascript:alert(1)"></iframe>

Why it works

Some attributes accept URLs, and javascript: is a valid executable scheme in some contexts.

Pros for attackers

  • Bypasses filters focused only on tags and event handlers
  • Fits naturally into links and embeds
  • Often missed in custom sanitizers

Cons for attackers

  • Less reliable in modern locked-down apps
  • Easier to neutralize with URL allowlists
  • Some browsers and contexts restrict behavior

Best defenses

1. Validate URL schemes

Only allow expected schemes:

function isSafeUrl(value) {
  try {
    const url = new URL(value, "https://example.com");
    return ["http:", "https:", "mailto:"].includes(url.protocol);
  } catch {
    return false;
  }
}

Then:

if (isSafeUrl(userUrl)) {
  link.href = userUrl;
} else {
  link.removeAttribute("href");
}

2. Sanitize URL-bearing attributes

If you allow HTML, inspect attributes like:

  • href
  • src
  • action
  • formaction
  • xlink:href

3. Prefer safe DOM APIs

const a = document.createElement("a");
a.textContent = label;
if (isSafeUrl(userUrl)) a.href = userUrl;

Blocking verdict

Best blocked by: URL protocol allowlisting
Backup control: sanitization and CSP
Common mistake: checking for the literal string javascript: without normalizing input

Attackers love obfuscation:

<a href="java&#x73;cript:alert(1)">

If your validation runs before decoding or normalization, you lose.


4. Attribute-breaking payloads

Example input inside an HTML attribute:

" autofocus onfocus=alert(1) x="

If your template does this:

<input value="{{ userInput }}">

and escaping is broken or disabled, the payload can break out of the attribute and add a new one.

Pros for attackers

  • Great against hand-built HTML strings
  • Doesn’t need <script>
  • Works in a lot of legacy code

Cons for attackers

  • Proper attribute encoding kills it completely
  • Auto-escaping templates usually handle this well
  • Can be harder to exploit in component-based frameworks

Best defenses

1. Attribute encoding

Escape quotes and special characters correctly for HTML attributes. Better yet, don’t build attributes with string concatenation.

Bad:

container.innerHTML = `<input value="${userInput}">`;

Good:

const input = document.createElement("input");
input.value = userInput;
container.appendChild(input);

2. Keep framework escaping enabled

React, Vue, Angular, and most server-side template engines are much safer when you stay in their normal rendering paths. The danger starts when someone reaches for escape hatches like:

  • dangerouslySetInnerHTML
  • v-html
  • manual string templates
  • custom DOM patching

Blocking verdict

Best blocked by: correct attribute encoding
Backup control: safe DOM APIs
Common mistake: hand-assembling HTML in JavaScript


5. SVG and “weird HTML” payloads

Examples:

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

Why it works

Browsers parse more than just plain HTML. SVG has its own event handlers and URL-bearing attributes. Attackers use these less-common elements to bypass simplistic filters.

Pros for attackers

  • Good against weak allowlist filters
  • Often overlooked in custom sanitizers
  • Can slip through rich content features

Cons for attackers

  • Strong sanitizers usually strip or heavily restrict SVG
  • CSP can reduce impact
  • Browser behavior is less predictable than basic HTML payloads

Best defenses

1. Restrict allowed elements hard

If you don’t explicitly need SVG from users, ban it. Same for MathML and embedded content.

2. Sanitize based on an allowlist, not a blocklist

Allow:

  • p
  • b
  • i
  • ul
  • li
  • a

Deny everything else by default.

This is the right mindset. Blocklists always miss something obscure.

Blocking verdict

Best blocked by: strict allowlist sanitization
Backup control: CSP
Common mistake: allowing “harmless formatting HTML” without defining what that means


6. Script-context payloads

These hit when untrusted data is placed inside JavaScript:

<script>
  const name = '{{ userInput }}';
</script>

Payload:

'; alert(1); //

Why it works

HTML escaping is not enough inside JavaScript. This is a different output context with different escaping rules.

Pros for attackers

  • Extremely powerful
  • Often leads to full script execution immediately
  • Missed by teams that think “escaped HTML” solves everything

Cons for attackers

  • Requires a vulnerable script embedding pattern
  • Safer serialization patterns are common in mature codebases

Best defenses

1. Don’t inject raw strings into script blocks

Bad:

<script>
  const data = '{{ userInput }}';
</script>

Better:

<script type="application/json" id="boot">
  {"name":"safe serialized value"}
</script>

Then parse it:

const data = JSON.parse(document.getElementById("boot").textContent);

Or serialize server-side with a proper JSON encoder.

2. Separate data from code

This one change eliminates a huge class of XSS bugs.

Blocking verdict

Best blocked by: JavaScript-context encoding or JSON serialization
Backup control: CSP
Common mistake: using HTML escaping inside JavaScript context


Which defense is best?

Here’s the short version:

  • For plain text output: use context-aware escaping and textContent
  • For user-supplied HTML: use a strict sanitizer allowlist
  • For links and embeds: validate URL schemes
  • For DOM updates: avoid innerHTML
  • For script bootstrapping: serialize as JSON, not executable code
  • For damage reduction: deploy CSP properly

CSP is great, but I wouldn’t treat it as the primary fix for XSS. It’s the seatbelt, not the brakes. If your app keeps piping attacker input into executable contexts, CSP just limits how badly things fail.

The biggest pro and con of each blocking strategy

Output encoding

Pros

  • Fast
  • Reliable
  • Built into many frameworks

Cons

  • Must match the exact output context
  • Fails if developers bypass safe rendering paths

HTML sanitization

Pros

  • Necessary when users can submit rich HTML
  • Handles more than simple escaping

Cons

  • Easy to misconfigure
  • Needs maintenance and testing

URL validation

Pros

  • Very effective for href and src
  • Simple allowlist model

Cons

  • Developers forget obscure URL-bearing attributes
  • Normalization bugs can break it

CSP

Pros

  • Limits exploitability
  • Helps catch unsafe inline patterns during rollout

Cons

  • Not a substitute for fixing the bug
  • Can be painful in legacy apps
  • Weak CSP policies give false confidence

If I had to be blunt: most XSS survives because teams rely on ad hoc filtering instead of choosing the right control for the right context. That’s the entire game. Stop treating user input as “HTML but cleaned a bit,” and most of these payloads die immediately.