
Sanitising HTML: Do Not Write Your Own
If your app renders HTML it did not write, you need a sanitiser. Regex is not a sanitiser, and the bypasses that prove it are more creative than you expect.
Any time you render HTML your application did not author — a rich-text field, a markdown comment, a CMS body, an imported document, an email preview — you are executing someone else's markup in your users' sessions.
React protects you by escaping everything, right up until dangerouslySetInnerHTML. The prop is named that way on purpose.
Why hand-rolled filtering fails
The instinct is to strip <script> tags. The bypasses are endless and well documented:
<img src=x onerror="alert(1)">
<svg><animate onbegin="alert(1)" attributeName=x dur=1s>
<a href="javascript:alert(1)">click</a>
<iframe srcdoc="<script>alert(1)</script>">
<style>@import 'https://evil.example/x.css'</style>
None of these contain the string <script>. Handling them requires parsing HTML the way a browser parses it, including its error recovery — which is exactly the thing a regular expression cannot do, and exactly what a sanitiser is.
Use one
import DOMPurify from 'dompurify'
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'code', 'pre', 'blockquote', 'h2', 'h3'],
ALLOWED_ATTR: ['href', 'title', 'dir', 'lang'],
})
Allowlist, never blocklist. A blocklist protects against what you remembered; an allowlist protects against everything you did not think of, which is the larger set.
Note dir and lang in the allowed attributes — on bilingual content those are needed for correct rendering of mixed Arabic and English, and stripping them produces text that is safe and unreadable.
Links deserve their own pass
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
node.setAttribute('rel', 'noopener noreferrer')
node.setAttribute('target', '_blank')
}
})
javascript: URLs are handled by the sanitiser; rel="noopener" handles the separate problem of a linked page getting a handle on your window.
Sanitise on the server too
Client-side sanitisation protects the person whose browser runs it. It does nothing about what is stored. If the raw payload is in your database, it will eventually be rendered somewhere that forgot to sanitise — an admin panel, an export, an email template. Clean it before it is persisted, and clean it again on output.
The better option when it is available
Do not accept HTML. Accept markdown, render it with a parser configured not to allow raw HTML, and the whole class of problem disappears. That is what this site does — articles are markdown, HTML is not passed through, and there is no sanitiser in the critical path because there is nothing to sanitise.
Reach for a sanitiser when you genuinely must render untrusted HTML. Prefer an architecture where you never do.
Resources
- Repo: cure53/DOMPurify
- Docs: github.com/cure53/DOMPurify
- Video walkthroughs: YouTube: dompurify xss sanitization tutorial
- Related: The OWASP cheat sheets
Need this built properly?
I build secure, fast, bilingual platforms for clients across Egypt, Saudi Arabia, the UAE and Kuwait.


