Technical GuideUpdated 2026-10-24

Preventing XSS when rendering untrusted HTML emails in a browser

By Koracle LLC EngineeringSystems Architect··11 min read
securityxssarchitecturedeveloper

Every webmail client, customer-support ticketing dashboard, and disposable inbox shares a uniquely hostile security requirement: it must render arbitrary HTML authored by completely untrusted external senders inside a user's browser session. If an attacker can send an email whose HTML executes JavaScript in the context of your application origin, they can read other inboxes, exfiltrate session state, or hijack the page DOM.

This guide breaks down the attack vectors hidden inside HTML email, explains why single-layer sanitization is fragile, and walks through the two-layer defense-in-depth architecture (HTML sanitization + opaque-origin <iframe sandbox=""> isolation) we use on tenmin.app.

Why HTML email is a minefield for browser rendering

Unlike Markdown comments or structured CMS content, marketing and transactional emails use sprawling, quirk-heavy HTML: deeply nested <table> grids, inline style attributes, VML conditional comments for Outlook, and external image beacons. You cannot simply strip all tags down to <p> and <b> without rendering verification emails and receipts unreadable.

At the same time, an adversary can craft an RFC 5322 email containing any payload they want and deliver it straight to your inbound SMTP port. Common XSS and UI-redressing vectors in raw HTML email include:

  • Inline event handlers on non-script elements: <img src="x" onerror="fetch(...)">, <svg onload="...">, <body onpageshow="...">, or <details ontoggle="...">.
  • Executable URI schemes: Anchor tags, form actions, or area maps pointing at javascript:..., vbscript:..., or base64-encoded data:text/html;base64,... payloads.
  • Document-level hijacking via <base> and <meta>: An injected <base href="https://attacker.example/"> rewrites every relative link and asset on the host page if rendered inline, while <meta http-equiv="refresh" content="0;url=..."> can force an automatic redirect.
  • CSS breakout and UI spoofing: Unscoped <style> blocks or position: fixed; inset: 0; z-index: 999999 overlays inside an inline email container can cover the surrounding webmail chrome with a fake login modal.
  • DOM clobbering: Elements with id="location" or name="Config" can shadow global variables accessed by application scripts when rendered in the same document tree.

Why rendering email directly in the host DOM is a mistake

Many developers reach for React's dangerouslySetInnerHTML paired with an HTML sanitizer library and render the email directly inside a <div> on the main page. Even with a battle-tested parser like DOMPurify, rendering untrusted email into the primary DOM has three structural flaws:

  1. Zero margin for parser differentials (mXSS): Browser HTML parsers mutate malformed markup inside namespaces like <svg> and <math>. Historically, every major HTML sanitizer has suffered mutation XSS bypasses when browsers updated their HTML5 parsing algorithms.
  2. Style and layout bleed: Email templates routinely define global selectors like body { background: #fff !important; } or a { color: blue; }, or rely on legacy table widths that break your application's responsive layout.
  3. Shared origin context: Any script that manages to execute inside a <div> runs under window.origin (e.g., https://tenmin.app), with full access to localStorage, sessionStorage, cookies, and the parent React state.

Defense Layer 1: Deterministic HTML sanitization

Before an email body ever touches the viewport, tenmin.app passes the parsed HTML string through a deterministic sanitization pass in frontend/src/components/Inbox.jsx that strips active content tags, event attributes, and executable URI schemes:

function sanitizeHtml(html) {
  if (!html) return "";
  return String(html)
    .replace(/<script\b[\s\S]*?<\/script>/gi, "")
    .replace(/<style\b[\s\S]*?<\/style>/gi, "")
    .replace(/<iframe\b[\s\S]*?<\/iframe>/gi, "")
    .replace(/\son\w+\s*=\s*"[^"]*"/gi, "")
    .replace(/\son\w+\s*=\s*'[^']*'/gi, "")
    .replace(/\son\w+\s*=\s*[^\s>]+/gi, "")
    .replace(/javascript:/gi, "blocked:");
}

On its own, regex-based sanitization would never be sufficient against a determined parser differential attack. Its role in our architecture is to strip known noise, styles, and inline scripts up front, while Layer 2 enforces a strict browser-level security boundary.

Defense Layer 2: Zero-privilege sandboxed iframes

Instead of mounting the sanitized markup into the host document, we render it inside an <iframe> using the srcDoc attribute and an empty sandbox="" attribute:

<iframe
  title="Email body"
  sandbox=""
  srcDoc={`<!doctype html>
    <meta charset="utf-8">
    <base target="_blank">
    <style>
      body {
        font: 14px/1.6 'Inter', system-ui, sans-serif;
        color: #0f172a;
        background: #ffffff;
        margin: 0;
        padding: 18px;
      }
      a { color: #2563eb; }
      img { max-width: 100%; height: auto; }
    </style>
    ${safeHtml}`}
/>

According to the WHATWG HTML specification, an <iframe sandbox=""> with zero permission tokens enforces the strictest possible isolation mode inside the browser's C++ rendering engine:

  • No script execution: Without allow-scripts, the browser refuses to execute any JavaScript inside the frame (including <script> tags, onerror handlers, and javascript: URIs), even if a payload bypassed the sanitizer.
  • Unique opaque origin (null): Without allow-same-origin, the iframe document is assigned a serialized unique opaque origin (null). It is treated as cross-origin to the parent page and cannot access window.parent.document, localStorage, or cookies.
  • No form submission or popups: Without allow-forms, allow-popups, or allow-top-navigation, the email cannot submit phishing forms from inside the frame or redirect the top-level browser tab.
  • Complete CSS containment: Any inline styles inside the email are scoped strictly to the iframe's document viewport and cannot overlay or spoof the surrounding inbox UI.

Handling external links and remote images safely

We inject <base target="_blank"> into the srcDoc header so that when a user clicks a verification link inside the email, the browser opens the URL in a new tab rather than attempting to navigate the nested frame. Combined with our site-wide Referrer-Policy: strict-origin-when-cross-origin HTTP header in public/_headers, clicking a verification link never leaks inbox URL parameters to the destination server.

For users inspecting suspicious messages who don't even want remote images or tracking pixels to load, tenmin.app also provides the Raw .eml download action so the message source can be inspected offline as plain text.

Further reading

Ready when you are

Need an ephemeral test inbox right now?

Generate a fresh @tenmin.app address in one click. Zero registration, sandboxed HTML rendering, automatic 600-second TTL deletion.