Sanitizing Input And Picking Safe Sinks
Most frameworks already sanitize for you unless you deliberately opt out, and that opt-out is the tell. textContent is a safe sink; innerHTML hands the DOM a loaded weapon.
The last post closed with a promise: the practical layer that actually stops most of this from ever landing. Here it is, and the reassuring part is that most of it is already working for you without any effort at all.
Reach for a Library, Not a Regex
The primary fix for cross-site scripting is sanitization, and for the overwhelming majority of real apps, this isn't something to build from scratch. Stripping angle brackets with a homemade regex gets you partway there and misses edge cases you won't think of until someone else finds them first.
Reach for a battle-tested library instead. DOMPurify is the specific one worth naming, actively maintained, and it runs in both the browser and in Node. Writing your own sanitizer means re-discovering, slowly and expensively, every edge case a maintained library already handles.
You're Probably Already Protected
Here's the part that surprised me. Most modern frameworks give you sanitization by default, not as an opt-in feature you have to remember to enable.
React, Vue, Svelte, and most server-side templating languages all auto-escape content unless a developer deliberately opts out. That opt-out is itself the tell. React didn't name its escape hatch setInnerHTML. It named it dangerouslySetInnerHTML, specifically so a developer has to stop and actually read the word "dangerously" before doing it.
That naming choice is doing real work. If you ever find yourself reaching for it, that's the moment to ask whether there's a safer way to get the same result, not a signal to shrug and move on.
Reflected vs. Stored, From a Defender's Chair
The category breakdown from earlier in this chapter already covered what stored and reflected XSS actually are. From a defender actually trying to shut one down, the difference shows up as two completely different response speeds.
A reflected XSS attack's entire existence depends on a live malicious link still circulating somewhere. Pull the query parameter out, or get the link taken down, and the active exploit path is effectively gone, even though the underlying code flaw that made it possible is still sitting there unpatched. Nobody is currently harmed by it, but the next crafted link works exactly as well as the last one did.
A stored XSS attack doesn't wait for a link. It persists for every single visitor to the affected page, until someone actually finds the bad database row and deletes it. That's why stored XSS deserves faster, more urgent attention, even though both categories genuinely need fixing. One is actively bleeding right now. The other is a wound waiting for someone to reopen it.
Safe Sinks vs. Dangerous Sinks
Sanitization on the way in matters, but so does what happens to a string on the way out, at the exact point it lands in the DOM. This is where the idea of a safe sink earns a permanent spot in how I read code now.
A safe sink treats its input as plain text, never as markup. textContent is the clearest example: whatever string you hand it renders exactly as written, visible on the page, and nothing inside it ever executes, no matter what it contains.
A dangerous sink does the opposite. innerHTML, document.write, and anything else that hands a raw string straight to the DOM as markup will parse and execute whatever that string contains, script tags, event handlers, all of it.
ExpandThe same malicious string handed to textContent, which renders it as inert visible text, versus innerHTML, which parses it as markup and executes the embedded script.
Same string, two different APIs, two completely different outcomes. That's worth having as a running mental model, not just a one-time audit item. It's exactly the kind of thing worth flagging in code review, and exactly the kind of thing a lint rule can catch automatically before a human ever has to notice it.
You can see this yourself with the fourth file in the code-practice repo: the same <img src=x onerror=...> string rendered through both sinks side by side, one inert, one firing.
DOMPurify's Repo Is Also a Test Suite
DOMPurify's own repository is worth a look for a reason beyond the library itself. It effectively publishes both an allow-list of what it considers safe markup, and a reference set of known-bad XSS payload strings used to test the library against.
That reference set is a genuinely useful, low-effort resource on its own. A small, periodic test suite, run against your own app's inputs, stubbing something like alert and asserting it never actually fires, turns those known-bad payloads into a regression check instead of a one-time audit. It doesn't need to run on every single CI build. Once a week, or before a release, catches drift long before it becomes an incident.
The Layer This Doesn't Replace
Sanitization stops most attacks from ever landing. It is not the only layer worth having, and MySpace's own sanitizer, blocking plenty and still missing one newline character, is the reminder of exactly why.
A policy-level defense can still save you on the day a sanitizer misses something it should have caught. That's Content Security Policy, and it's the next layer this course covers.
The Essentials
- Use a maintained sanitization library like DOMPurify instead of hand-rolling a regex. It handles edge cases a homemade sanitizer won't catch until someone else finds them.
- Most frameworks auto-escape by default. An escape hatch like
dangerouslySetInnerHTMLis deliberately named to make you stop and think before using it. - Reflected XSS dies when the malicious link stops circulating. Stored XSS persists for every visitor until the bad database row is actually deleted, which is why it needs faster attention.
textContentis a safe sink that treats input as plain text.innerHTMLanddocument.writeare dangerous sinks that parse input as executable markup.- DOMPurify's repo publishes both an allow-list and known-bad payloads. Running that payload set as a periodic regression test catches drift before it becomes an incident.
- Sanitization stops most attacks, not all of them. A policy-level defense is the layer that can still save you the day a sanitizer misses something, the way MySpace's did.
Next up: Content Security Policy, the layer that catches what sanitization misses.
Further Reading and Watching
- DOMPurify: The Easy Way to Stop XSS!: a practical walkthrough of sanitizing untrusted HTML with DOMPurify.
- DOMPurify: the library's own repository, including its allow-list defaults and its reference set of known-bad XSS payloads.
Keep reading