GuidesFIGMA PLUGINS

Fix Figma plugin fetch requests blocked by CORS

Figma plugins run in the browser with a null origin, so fetch() from a plugin succeeds only against APIs that answer Access-Control-Allow-Origin: *; any other response is blocked before your code sees it. cors.dev answers every anonymous request with that exact header, so for public GET requests you put https://proxy.cors.dev/ in front of the URL and add that domain to networkAccess.allowedDomains in manifest.json. The free tier needs no signup and no API key and relays JSON, XML and HTML up to 1 MiB within 10 seconds.

Why plugin requests get blocked

Figma documents it plainly: because plugins run inside a browser environment, CORS applies, plugin iframes have a null origin, and they can only call APIs that allow access from any origin. A null origin cannot be matched by name, so a server that echoes specific origins, or sends Access-Control-Allow-Origin: https://www.figma.com, still fails. The request shows up in the browser console as blocked by CORS policy; your plugin code only sees TypeError: Failed to fetch.

A second gate sits in front of it. Since the networkAccess manifest field exists, a plugin may only talk to the domains listed in allowedDomains; a request to anything else is stopped by the Figma content security policy and logged as a CSP error, which looks similar but has nothing to do with the API. Both gates must be open: the domain allowed in the manifest, and the response carrying Access-Control-Allow-Origin: *.

Allow the proxy domain in the manifest

The browser connects to proxy.cors.dev only; the API host is part of the path, so it is the proxy domain you list. reasoning is optional for a specific domain and required when allowedDomains contains *. Use devAllowedDomains for local servers during development instead of widening the public list.

manifest.json: allow the proxy domain
{
  "name": "Headline Filler",
  "id": "1234567890123456789",
  "api": "1.0.0",
  "main": "code.js",
  "ui": "ui.html",
  "editorType": ["figma"],
  "documentAccess": "dynamic-page",
  "networkAccess": {
    "allowedDomains": ["https://proxy.cors.dev"],
    "reasoning": "Fetches public RSS headlines through a CORS proxy to fill text layers."
  }
}

Fetch through the proxy from code.ts or ui.html

fetch is available in the main plugin thread as well as in the UI iframe, and both carry a null origin, so the same prefix works in either place. Verified while writing this page: a request with Origin: null to the proxy comes back with Access-Control-Allow-Origin: * and X-Cors-Source: upstream. The example fills selected text layers with live headlines from an RSS feed that sends no CORS header, the kind of real content a mockup plugin needs.

code.ts: fetch public data and write it into text layers
const PROXY = 'https://proxy.cors.dev/';
const FEED = 'https://feeds.bbci.co.uk/news/technology/rss.xml';

async function fetchHeadlines(): Promise<string[]> {
  // The plugin origin is null; the proxy answers Access-Control-Allow-Origin: *
  const response = await fetch(PROXY + FEED);
  if (!response.ok) {
    throw new Error(`Feed request failed: ${response.headers.get('X-Cors-Error') ?? response.status}`);
  }
  const xml = await response.text();

  // No DOMParser in the plugin sandbox: a small regex is enough for RSS titles
  return [...xml.matchAll(/<item>[\s\S]*?<title>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/g)]
    .map((match) => match[1].trim());
}

async function fillSelection() {
  const textNodes = figma.currentPage.selection.filter((node): node is TextNode => node.type === 'TEXT');
  if (textNodes.length === 0) {
    figma.closePlugin('Select one or more text layers first.');
    return;
  }

  const headlines = await fetchHeadlines();
  for (const [index, node] of textNodes.entries()) {
    await figma.loadFontAsync(node.fontName as FontName);
    node.characters = headlines[index % headlines.length];
  }
  figma.closePlugin(`Filled ${textNodes.length} layers.`);
}

fillSelection().catch((error: Error) => figma.closePlugin(error.message));

Anonymous requests cover GET and HEAD to any public HTTPS host and forward only Accept, If-None-Match and If-Modified-Since; a custom header such as X-Api-Key triggers a preflight and is rejected with header_not_allowed. Responses are relayed with the upstream status and Content-Type, and a proxy-side failure sets X-Cors-Error, for example response_too_large above 1 MiB or upstream_timeout after 10 seconds.

If the API is yours: send the wildcard

For a backend you run for your plugin, answer Access-Control-Allow-Origin: * and skip the proxy; the response cannot use credentials with a wildcard, which is fine because a plugin has no cookies to send anyway. Avoid Access-Control-Allow-Origin: null: it also admits sandboxed iframes and file: pages on any site, and browsers treat it as a misconfiguration. Authenticate plugin users with a token in the request instead, over a Pro Connection or directly against your own server.

Limits and pricing

cors.dev limits for a Figma plugin
LimitFree, no keyPro, $5 per month
Price$0$5 per month; 7-day trial with 1,000 requests, no card
Works from a null originYes, answers Access-Control-Allow-Origin: *No: keyed requests need an http or https page origin
MethodsGET and HEADGET, HEAD, POST, PUT, PATCH and DELETE
DestinationsAny public HTTPS hostPublic HTTPS hosts enabled on your Connection
Response size1 MiB, text types only6 MiB, any content type
Upstream deadline10 seconds10 seconds
RateShared pool with fair-use limits600 requests per minute, 10 concurrent per account
Monthly requestsNo quota500,000 per billing period, $5 per extra 500,000
Request headers forwardedAccept, If-None-Match, If-Modified-SincePlus Authorization and custom headers
RedirectsFollowed, up to 4 hopsFollowed when every hop host is enabled

Good questions.

The API works in a browser tab, why not in my plugin?

In a tab the page origin is a real https origin, often the same one as the API. The plugin origin is null, which only a wildcard Access-Control-Allow-Origin satisfies. The proxy answers the wildcard for every anonymous request.

Do I list the API domain or the proxy domain in networkAccess?

The proxy domain, https://proxy.cors.dev, because that is the only host the browser connects to. The API URL travels inside the path and is not subject to the manifest check.

Can I use a Pro Connection from a plugin?

No. Keyed requests need a page served over http or https so the Connection can match an origin, and a plugin has none. Plugins use the anonymous tier: GET and HEAD to public APIs, no Authorization header, no custom headers.

How do I send an API key then?

Not through the anonymous proxy, which forwards no custom headers and rejects credential-like query names such as key. A key inside a published plugin is public anyway, so put keyed calls behind a small backend of your own that sends Access-Control-Allow-Origin: *.

Does fetch work in code.ts or only in the UI iframe?

Both. The main thread has had fetch for a while, and both contexts carry a null origin and the same networkAccess rules, so choose whichever keeps your code simpler.

Why do I get a 403 with X-Cors-Error: owner_opted_out?

The owner of that domain verified an opt-out, and the proxy refuses requests to it and its subdomains for everyone. Use a different source or call the site from a server of your own.