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:
onerroronclick- all
on*event handlers - dangerous elements like
script,iframe,objectunless 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:
hrefsrcactionformactionxlink: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="javascript: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:
dangerouslySetInnerHTMLv-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:
pbiullia
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
hrefandsrc - 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.