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.
{
"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.
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
| Limit | Free, no key | Pro, $5 per month |
|---|---|---|
| Price | $0 | $5 per month; 7-day trial with 1,000 requests, no card |
| Works from a null origin | Yes, answers Access-Control-Allow-Origin: * | No: keyed requests need an http or https page origin |
| Methods | GET and HEAD | GET, HEAD, POST, PUT, PATCH and DELETE |
| Destinations | Any public HTTPS host | Public HTTPS hosts enabled on your Connection |
| Response size | 1 MiB, text types only | 6 MiB, any content type |
| Upstream deadline | 10 seconds | 10 seconds |
| Rate | Shared pool with fair-use limits | 600 requests per minute, 10 concurrent per account |
| Monthly requests | No quota | 500,000 per billing period, $5 per extra 500,000 |
| Request headers forwarded | Accept, If-None-Match, If-Modified-Since | Plus Authorization and custom headers |
| Redirects | Followed, up to 4 hops | Followed 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.