Figma plugins feel deceptively safe.

A lot of developers assume, “It’s a design tool, not a public website, so XSS probably isn’t a big deal here.” That assumption gets people into trouble fast. Figma plugins are basically little web apps glued onto a privileged plugin runtime. You still have HTML, JavaScript, message passing, user-controlled content, and the usual temptation to throw untrusted strings into innerHTML.

That’s enough for XSS.

The exact blast radius is different from a normal browser app, but the root problem is the same: untrusted data reaches a dangerous sink and runs as code.

Where XSS happens in a Figma plugin

A Figma plugin usually has two sides:

  1. Plugin code (code.ts or code.js) running in the Figma plugin environment
  2. UI code (ui.html + frontend JS) running in an embedded browser context

The UI side is where most XSS bugs show up, because it behaves like a mini web page. If your plugin takes input from:

  • selected layer names
  • text node content
  • shared library metadata
  • imported JSON
  • remote API responses
  • plugin parameters
  • postMessage payloads

and drops any of that into the DOM unsafely, you have an XSS bug.

A common mistake is treating document content as trusted. It isn’t. If a plugin reads the name of a layer from a Figma file, that string may have been created by anyone. If your org opens community files, copies components around, or imports third-party content, you already have an attacker-controlled input path.

A vulnerable example

Here’s a very normal pattern: plugin code grabs selected nodes and sends them to the UI.

code.ts:

figma.showUI(__html__, { width: 400, height: 300 });

function sendSelection() {
  const data = figma.currentPage.selection.map(node => ({
    id: node.id,
    name: node.name
  }));

  figma.ui.postMessage({
    type: "selection-data",
    items: data
  });
}

figma.on("selectionchange", sendSelection);
sendSelection();

Then the UI renders it.

ui.html:

<!doctype html>
<html>
  <body>
    <div id="results"></div>

    <script>
      window.onmessage = (event) => {
        const message = event.data.pluginMessage;
        if (message.type === "selection-data") {
          const html = message.items
            .map(item => `<div class="row">${item.name}</div>`)
            .join("");

          document.getElementById("results").innerHTML = html;
        }
      };
    </script>
  </body>
</html>

That is vulnerable.

If a layer name is:

<img src=x onerror="alert('XSS in plugin UI')">

your plugin UI executes attacker-controlled JavaScript.

People sometimes shrug at this because it “only” hits the plugin UI. I think that’s the wrong mindset. Once arbitrary script runs inside your plugin UI, the attacker can:

  • manipulate what the user sees
  • send malicious messages back to plugin code
  • steal any secrets exposed to the UI
  • abuse API tokens stored in the frontend
  • trigger plugin actions on behalf of the user

That’s enough to do real damage.

The easy fix: stop using innerHTML

If you only need to display text, use textContent.

<!doctype html>
<html>
  <body>
    <div id="results"></div>

    <script>
      window.onmessage = (event) => {
        const message = event.data.pluginMessage;
        if (message.type === "selection-data") {
          const container = document.getElementById("results");
          container.innerHTML = "";

          for (const item of message.items) {
            const row = document.createElement("div");
            row.className = "row";
            row.textContent = item.name;
            container.appendChild(row);
          }
        }
      };
    </script>
  </body>
</html>

This one change kills a huge chunk of Figma plugin XSS.

My rule is simple: if the value came from outside the current function, I assume it’s hostile until proven otherwise.

If you really need HTML, sanitize it

Sometimes plugins intentionally render rich text previews, Markdown output, or remote content snippets. In those cases, encoding everything as text may not work.

Use a sanitizer like DOMPurify in the UI layer.

<!doctype html>
<html>
  <body>
    <div id="preview"></div>

    <script src="https://unpkg.com/[email protected]/dist/purify.min.js"></script>
    <script>
      function renderPreview(untrustedHtml) {
        const clean = DOMPurify.sanitize(untrustedHtml, {
          ALLOWED_TAGS: ["b", "i", "em", "strong", "p", "br", "ul", "li", "code"],
          ALLOWED_ATTR: []
        });

        document.getElementById("preview").innerHTML = clean;
      }

      window.onmessage = (event) => {
        const message = event.data.pluginMessage;
        if (message.type === "render-preview") {
          renderPreview(message.html);
        }
      };
    </script>
  </body>
</html>

Sanitization is a fallback, not a license to dump random HTML into the DOM. If plain text works, use plain text.

Message passing is part of the attack surface

Figma plugins rely heavily on messages between UI and plugin code. That boundary gets ignored too often.

Here’s a bad UI-to-plugin handler:

code.ts:

figma.ui.onmessage = async (msg) => {
  if (msg.type === "create-text") {
    const node = figma.createText();
    await figma.loadFontAsync({ family: "Inter", style: "Regular" });
    node.characters = msg.content;
    figma.currentPage.appendChild(node);
  }

  if (msg.type === "export-node") {
    const target = await figma.getNodeByIdAsync(msg.nodeId);
    const bytes = await target.exportAsync();
    figma.ui.postMessage({
      type: "export-result",
      data: Array.from(bytes)
    });
  }
};

If the UI gets compromised by XSS, the attacker now has a nice command channel into privileged plugin actions.

The fix isn’t just “don’t get XSS.” You should also validate messages as if they came from an attacker.

function isCreateTextMessage(msg: unknown): msg is { type: "create-text"; content: string } {
  return !!msg &&
    typeof msg === "object" &&
    (msg as any).type === "create-text" &&
    typeof (msg as any).content === "string" &&
    (msg as any).content.length <= 1000;
}

function isExportNodeMessage(msg: unknown): msg is { type: "export-node"; nodeId: string } {
  return !!msg &&
    typeof msg === "object" &&
    (msg as any).type === "export-node" &&
    typeof (msg as any).nodeId === "string";
}

figma.ui.onmessage = async (msg) => {
  if (isCreateTextMessage(msg)) {
    await figma.loadFontAsync({ family: "Inter", style: "Regular" });
    const node = figma.createText();
    node.characters = msg.content;
    figma.currentPage.appendChild(node);
    return;
  }

  if (isExportNodeMessage(msg)) {
    const target = await figma.getNodeByIdAsync(msg.nodeId);
    if (!target) return;

    const bytes = await target.exportAsync();
    figma.ui.postMessage({
      type: "export-result",
      data: Array.from(bytes)
    });
  }
};

That won’t magically stop abuse after a UI compromise, but it reduces the damage and blocks malformed payloads.

Remote data is still untrusted data

A lot of plugins fetch design tokens, CMS content, component docs, or internal API responses and show them in the UI. That’s classic stored XSS territory.

Bad:

async function loadReleaseNotes() {
  const res = await fetch("https://api.example.com/release-notes");
  const data = await res.json();

  document.getElementById("notes").innerHTML = data.html;
}

Better:

async function loadReleaseNotes() {
  const res = await fetch("https://api.example.com/release-notes");
  const data = await res.json();

  document.getElementById("notes").textContent = data.text;
}

Or if rich formatting is required, sanitize before rendering.

Also, don’t trust “internal APIs” just because they’re internal. Plenty of plugin ecosystems end up consuming content from systems where editors, marketers, or external contributors can inject markup.

CSP helps, even in plugin UIs

A Content Security Policy won’t fix unsafe DOM code, but it can make XSS harder to exploit. If an attacker lands a string injection but can’t run inline scripts or load arbitrary external code, you’ve raised the bar.

A basic CSP for a Figma plugin UI might look like this:

<meta
  http-equiv="Content-Security-Policy"
  content="
    default-src 'self';
    script-src 'self';
    style-src 'self' 'unsafe-inline';
    img-src 'self' data: https:;
    connect-src https://api.example.com;
    object-src 'none';
    base-uri 'none';
    frame-ancestors 'none';
  ">

The exact policy depends on how your plugin is built. If you want a deeper CSP reference, csp-guide.com is a solid implementation resource.

If you’re exposing any web endpoints that support your plugin, I’d also verify your security headers with a quick scan tool like HeaderTest. That won’t audit your plugin UI logic, but it does catch weak CSP and missing browser protections on the backend surfaces plugins often rely on.

Avoid these common Figma plugin mistakes

I keep seeing the same patterns:

1. Rendering layer names with template strings

list.innerHTML += `<li>${node.name}</li>`;

Don’t do this.

2. Trusting imported JSON

preview.innerHTML = importedData.description;

JSON is just a transport format. It doesn’t make the contents safe.

3. Exposing secrets to the UI

If an API token lives in the UI and XSS hits, that token is gone. Keep secrets out of the frontend whenever possible.

4. Treating plugin messages like function calls

They’re attacker input. Validate them.

5. Using eval, new Function, or dynamic script injection

This should be obvious, but I’ve seen plugins build code snippets dynamically for “templating” or “expression support.” That’s self-inflicted pain.

A safer rendering helper

I like having one tiny utility for text-only rendering so nobody reaches for innerHTML out of habit.

function appendTextRow(container, className, text) {
  const el = document.createElement("div");
  el.className = className;
  el.textContent = String(text ?? "");
  container.appendChild(el);
}

Then use it everywhere:

window.onmessage = (event) => {
  const message = event.data.pluginMessage;
  if (message.type !== "selection-data") return;

  const results = document.getElementById("results");
  results.replaceChildren();

  for (const item of message.items) {
    appendTextRow(results, "row", item.name);
  }
};

Boring code is good security code.

A practical review checklist

Before shipping a Figma plugin, I’d check these first:

  • Search for innerHTML, outerHTML, insertAdjacentHTML
  • Search for eval, Function, dynamic <script> creation
  • Review every postMessage and onmessage path
  • Trace all data from layer names, text nodes, imported files, and APIs into the UI
  • Sanitize any rich HTML that must be rendered
  • Add a CSP to the UI
  • Keep secrets out of the UI runtime
  • Fuzz test with payloads like:
    • <img src=x onerror=alert(1)>
    • <svg onload=alert(1)>
    • "><img src=x onerror=alert(1)>

Figma plugins are just specialized web apps. If you treat them like toy scripts, you’ll ship toy security. If you treat every document, message, and API response as hostile, most XSS bugs become pretty easy to avoid.