Untrusted input reaching a dangerous sink becomes DOM-based XSS only if every applicable defense fails to neutralise it first. Each payload passes through an ordered gate chain and is blocked at the first gate that applies to its sink:
execute(sink) = payload reaches sink
AND ¬blocked_by(Encoding, sink)
AND ¬blocked_by(Purify, sink)
AND ¬blocked_by(CSP, sink)
Crucially, a defense only helps if it matches the sink's execution context — this is the real cause of most "we sanitized it and got popped anyway" incidents:
| Sink | Encoding | DOMPurify | Strict CSP |
| innerHTML | ✓ blocks | ✓ blocks | ✓ blocks |
| document.write() | ✓ blocks | ✓ blocks | ✓ blocks |
| eval(input) | ✗ no effect | ✗ no effect | ✓ blocks |
| onclick="…" attr | ✓ blocks | ✓ blocks | ✓ blocks |
| href="javascript:…" | ✗ no effect | ✓ blocks | ✗ no effect |
- Output Encoding escapes HTML metacharacters (< > " ' &) so markup becomes inert text — but it does nothing to a string handed straight to
eval() or written into a javascript: URL, because neither is HTML; the encoded text is still valid code or a valid URL scheme.
- DOMPurify / allowlist sanitization parses the fragment as HTML and strips disallowed tags, event-handler attributes and dangerous URL schemes (including
javascript:) — but it never sees data passed directly to eval(), which is never parsed as HTML.
- Strict CSP (no
unsafe-inline, no unsafe-eval) makes the browser itself refuse to run injected inline handlers or call eval, independent of what the application code did — but it does not stop a user from clicking a malicious javascript: link, since that isn't script execution CSP's script-src directive governs.
The takeaway modeled here: no single control is a universal fix. Real defense-in-depth pairs each sink with the specific control that matches its context, and stacks all three so a gap in one is covered by another.