Notion embeds feel harmless. Paste a link, get a nice preview, move on. That’s exactly why teams get sloppy with them.

I’ve seen engineers treat “embed” as a trusted content type, when it’s really just another path for untrusted input to land in your app. If your product accepts Notion pages, renders Notion-derived HTML, proxies embed URLs, or wraps third-party content in iframes, you’re dealing with XSS risk whether you planned for it or not.

Notion itself has guardrails. Your app might not.

Here are the mistakes I see most often when teams integrate Notion embeds, and how to fix them without wrecking the user experience.

Mistake #1: Treating Notion content as trusted because it came from Notion

This is the classic mental trap: “It’s from Notion, so it’s safe.”

Nope.

The moment you fetch page content from the Notion API, transform blocks into HTML, or store pasted embed URLs for later rendering, you’re handling untrusted data. A malicious user can still control page text, URLs, captions, and sometimes rich content that your renderer turns into dangerous markup.

Bad pattern:

app.get('/notion-page/:id', async (req, res) => {
  const page = await notion.pages.retrieve({ page_id: req.params.id });
  const html = renderNotionPageToHtml(page);
  res.send(`<div class="notion-content">${html}</div>`);
});

If renderNotionPageToHtml() allows raw HTML, inline event handlers, or unsafe URLs, you’ve already lost.

Fix

Sanitize after rendering and before output. Don’t assume your Notion renderer got every edge case right.

import sanitizeHtml from 'sanitize-html';

function safeRenderNotionHtml(unsafeHtml) {
  return sanitizeHtml(unsafeHtml, {
    allowedTags: [
      'p', 'a', 'ul', 'ol', 'li', 'strong', 'em', 'code',
      'pre', 'blockquote', 'h1', 'h2', 'h3', 'img'
    ],
    allowedAttributes: {
      a: ['href', 'title', 'target', 'rel'],
      img: ['src', 'alt', 'title']
    },
    allowedSchemes: ['http', 'https', 'mailto'],
    allowProtocolRelative: false
  });
}

Then render only sanitized output:

const rawHtml = renderNotionPageToHtml(page);
const safeHtml = safeRenderNotionHtml(rawHtml);
res.send(`<div class="notion-content">${safeHtml}</div>`);

If your framework supports trusted template escaping by default, keep it that way. Don’t punch holes in it with dangerouslySetInnerHTML, v-html, or raw string concatenation unless the content has gone through a serious sanitizer.

Mistake #2: Validating embed URLs with string matching

A lot of teams “validate” Notion embeds like this:

if (url.includes('notion.so')) {
  saveEmbedUrl(url);
}

That’s not validation. That’s wishful thinking.

Attackers love weak hostname checks:

  • https://notion.so.evil.com
  • https://evil.com/?redirect=notion.so
  • javascript:alert(1)//notion.so

If you later stick that URL into an iframe, link, or server-side fetcher, things get ugly fast.

Fix

Parse URLs properly and allowlist exact hosts and schemes.

function isAllowedNotionUrl(input) {
  try {
    const url = new URL(input);

    const allowedHosts = new Set([
      'www.notion.so',
      'notion.so'
    ]);

    return (
      (url.protocol === 'https:') &&
      allowedHosts.has(url.hostname)
    );
  } catch {
    return false;
  }
}

If you support additional Notion-owned domains, list them explicitly. Don’t use suffix matching unless you really understand the edge cases.

Also normalize before storing. Save the parsed canonical URL, not the raw user string.

Mistake #3: Dropping embed URLs straight into iframe src

This usually shows up in dashboard builders and internal tools:

<iframe src="{{embedUrl}}" width="100%" height="600"></iframe>

If embedUrl is attacker-controlled and your validation is weak, you just handed them a script execution primitive, phishing surface, or data exfil channel.

Even when the iframe content is cross-origin, you can still create nasty problems: fake login prompts, clickjacking bait, or unsafe postMessage interactions.

Fix

Lock down the iframe aggressively.

<iframe
  src="{{safeEmbedUrl}}"
  sandbox="allow-scripts allow-same-origin"
  referrerpolicy="no-referrer"
  loading="lazy"
  width="100%"
  height="600">
</iframe>

A few opinions here:

  • Start with sandbox and add capabilities only when you actually need them.
  • Don’t give allow-top-navigation unless you enjoy incident response.
  • Be careful with allow-same-origin. Combined with allow-scripts, it restores a lot of power to the framed content. Sometimes you need it; often you don’t.
  • Set a restrictive Permissions-Policy at the page level so embeds don’t get camera, mic, geolocation, and friends for free.

If you want to sanity-check the headers around pages serving embeds, HeaderTest is a quick way to spot missing protections.

Mistake #4: Forgetting that postMessage can turn an iframe into an XSS bridge

This one bites experienced teams too. You embed Notion or a wrapper around Notion, then add postMessage to resize the frame, sync state, or trigger actions.

Then somebody writes this:

window.addEventListener('message', (event) => {
  if (event.data.type === 'setHtml') {
    document.getElementById('preview').innerHTML = event.data.html;
  }
});

That’s an XSS sink with a welcome mat.

If any embedded frame can message the parent, and the parent trusts message data, an attacker can inject HTML or script-bearing payloads through the messaging channel.

Fix

Verify origin, verify message shape, and never pipe message data into unsafe DOM sinks.

const allowedOrigins = new Set([
  'https://www.notion.so',
  'https://notion.so'
]);

window.addEventListener('message', (event) => {
  if (!allowedOrigins.has(event.origin)) return;
  if (!event.data || typeof event.data !== 'object') return;

  switch (event.data.type) {
    case 'resize':
      if (typeof event.data.height === 'number' && event.data.height > 0) {
        document.getElementById('embed-frame').style.height = `${event.data.height}px`;
      }
      break;
  }
});

And if you need to display text from a message, use textContent, not innerHTML.

statusEl.textContent = event.data.status;

I’d also recommend defining a tiny schema for messages and rejecting everything else. “Flexible” message handlers age badly.

Mistake #5: Using innerHTML to render Notion snippets, titles, or captions

The XSS story is often less about the full embed and more about the little UI around it.

Teams sanitize the main content, then forget the sidebar preview, card title, hover tooltip, or search result snippet.

Bad:

card.innerHTML = `
  <h3>${notionPage.title}</h3>
  <p>${notionPage.caption}</p>
`;

If either field contains markup or a payload that survives your upstream processing, the page is compromised.

Fix

Use safe DOM APIs for plain text.

const h3 = document.createElement('h3');
h3.textContent = notionPage.title;

const p = document.createElement('p');
p.textContent = notionPage.caption;

card.replaceChildren(h3, p);

If you truly need rich formatting, sanitize it first and keep the allowed HTML surface tiny.

Mistake #6: Shipping no CSP, or shipping a fake one

A lot of apps that support embeds either have no Content Security Policy or have a “CSP” that boils down to this:

Content-Security-Policy: default-src * 'unsafe-inline' 'unsafe-eval' data: blob:;

That’s not defensive. That’s decorative.

CSP won’t fix bad sanitization, but it does reduce blast radius when something slips through. For pages rendering Notion-derived content, I like a policy that is strict by default and explicit about frame sources.

Example starting point:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m';
  style-src 'self' 'unsafe-inline';
  img-src 'self' https: data:;
  frame-src https://www.notion.so https://notion.so;
  connect-src 'self' https://api.notion.com;
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'self';

You’ll need to tune this for your app, especially if you use a frontend bundle, analytics, or third-party assets. If you want the nuts and bolts of rolling out a sane policy, csp-guide.com is a solid reference.

My opinion: if your app renders any user-influenced HTML and you don’t have CSP, you’re accepting avoidable risk.

Mistake #7: Proxying embed content server-side without sanitizing the response

Sometimes teams proxy Notion content to avoid CORS issues or to cache embed data:

app.get('/embed-proxy', async (req, res) => {
  const response = await fetch(req.query.url);
  const html = await response.text();
  res.send(html);
});

This is a mess:

  • SSRF risk if url is user-controlled
  • Reflected or stored XSS if the proxied content is served from your origin
  • Security boundary collapse, because now hostile HTML appears same-origin

Fix

Don’t proxy arbitrary HTML. If you must proxy, fetch only from a strict allowlist and transform the data into a safe format before serving it.

Better:

app.get('/notion-meta', async (req, res) => {
  const url = req.query.url;
  if (!isAllowedNotionUrl(url)) {
    return res.status(400).json({ error: 'invalid url' });
  }

  const response = await fetch(url, {
    redirect: 'error'
  });

  const text = await response.text();

  const metadata = extractSafeMetadata(text); // title, description, image
  res.json(metadata);
});

Serve JSON, not raw HTML, unless you’re prepared to sanitize aggressively and isolate the result.

Mistake #8: Ignoring fallback states and error rendering

Error paths are where weird XSS bugs hide. I’ve seen apps escape normal content correctly, then render failed embed URLs into raw HTML during errors:

errorBox.innerHTML = `Failed to load embed: ${embedUrl}`;

If embedUrl contains attacker-controlled markup, your error UI becomes the exploit path.

Fix

Treat error messages as untrusted too.

errorBox.textContent = `Failed to load embed: ${embedUrl}`;

Same rule for logs shown in admin panels, moderation tools, or internal debugging pages. Internal surfaces get attacked all the time because people assume only staff can see them.

A safer baseline for Notion embeds

If I were building Notion embed support today, my baseline would be:

  • Strict allowlist for accepted Notion URLs
  • Sandboxed iframes with minimal permissions
  • No raw HTML rendering without sanitization
  • No innerHTML for titles, captions, or error messages
  • Tight postMessage origin checks
  • CSP deployed and tested
  • No same-origin proxying of arbitrary embed HTML

That’s not overkill. That’s the minimum for a feature that mixes user input, third-party content, and browser rendering.

Embeds are convenient, but convenience is exactly what creates security debt. If your Notion integration feels “simple,” that’s when I’d look twice.