7 min read1,454 words

Fixing Userscript Cross-Origin Request Errors

Two browser windows with a blocked connection between them

A userscript that needs to fetch data from a different domain than the page it runs on will usually hit a cross-origin error if it uses the standard fetch or XMLHttpRequest APIs. Userscript managers provide a dedicated API to work around this, and understanding it avoids a lot of confused debugging.

userscriptscorsdebugging
Share on XHacker News

Why fetch fails under CORS

The browser’s same-origin policy blocks a page, or code running as part of that page, from reading responses from a different origin unless that origin explicitly allows it through CORS response headers. A userscript injected into a page runs as part of that page’s origin for the purpose of these checks, so a plain fetch call to another domain fails the same way it would if the page’s own JavaScript tried it.

This is not a bug in the userscript manager. It is the same restriction any web page faces, and the fix is to avoid the browser’s standard networking APIs for cross-origin calls entirely.

GM_xmlhttpRequest and GM.xmlHttpRequest

Userscript managers provide GM_xmlhttpRequest, and its promise-based equivalent GM.xmlHttpRequest, specifically to make cross-origin requests from within a userscript. These functions run through the extension’s own privileged background context rather than the page’s content script context, so they are not subject to the same-origin policy in the same way a page-level fetch is.

The call takes an object with a url, method, headers and callback options such as onload and onerror, rather than returning a standard fetch Response, so existing fetch-based code needs to be adapted rather than simply swapped in.

A concrete example

A script that needs to fetch JSON from api.example.com from a page hosted on a different domain would declare @connect api.example.com in its metadata block, then call GM_xmlhttpRequest with the target url, method set to GET, and an onload callback that reads response.responseText. Inside onload, the script would call JSON.parse on responseText to get a usable object, then update the page’s DOM with the result. An onerror callback should also be defined so a network failure produces a visible console message instead of silently doing nothing.

The @connect directive

Before a script can call GM_xmlhttpRequest against a given domain, its metadata block needs an @connect line naming that domain, for example @connect api.example.com. Managers use this declaration to show users which external hosts a script will talk to, similar to how @match shows which pages it runs on.

Missing an @connect entry for a domain you are requesting from is one of the most common reasons a cross-origin call silently fails or is blocked, so check the metadata block first when a request does not go through.

A single script can list multiple @connect lines, one per domain, and some managers accept a wildcard subdomain entry such as @connect *.example.com to cover several subdomains at once without listing each one individually.

The permission prompt

The first time a script attempts a cross-origin request to a newly listed @connect domain, some managers show a one-time permission prompt asking whether to allow it. This is separate from the install-time review and exists specifically to flag network activity that was not obvious from installing the script alone.

Handling the response

GM_xmlhttpRequest passes the result to your onload callback as an object containing responseText, status, and response headers as a string you need to parse yourself, rather than the structured Headers object fetch provides. If the remote endpoint returns JSON, call JSON.parse on responseText rather than expecting a built-in response.json method.

CSP issues

A page’s own Content-Security-Policy header can restrict what its embedded scripts are allowed to connect to, and in some manager configurations this same CSP can apply to requests made from injected content scripts. If a request fails with a CSP-related console error rather than a CORS error, check whether the manager offers a setting to bypass page CSP for GM_xmlhttpRequest calls, since some do and some do not depending on browser and manifest version.

Troubleshooting checklist

When a cross-origin request in a userscript is not working, work through these checks before assuming the remote server is at fault.

  • Confirm @connect lists the exact domain, including any subdomain, used in the request URL
  • Check the browser console for a CORS error versus a CSP error versus a plain network failure, since each points to a different fix
  • Confirm @grant includes GM_xmlhttpRequest or GM.xmlHttpRequest, since an empty or missing @grant line will make the function unavailable
  • Check whether a one-time permission prompt appeared and was dismissed or denied rather than approved
  • Log the full response object in onload during development to confirm status and headers rather than assuming a successful call
  • Test the same request from a plain script outside the page, such as curl, to confirm the endpoint itself responds as expected before blaming the userscript

Performance notes for network-heavy scripts

Because GM_xmlhttpRequest routes through the extension’s background context, a script issuing many requests in quick succession can hit whatever rate limiting the manager or browser applies to background network activity. Batching requests, adding a short delay between calls, or caching results with GM_setValue for a reasonable period all reduce unnecessary repeated calls and make a script feel faster to the end user as well.

When a userscript is the wrong tool

If the data you need requires authentication tied to a session the user has to actively manage, or if the remote service explicitly disallows automated client-side requests in its terms, a userscript calling GM_xmlhttpRequest is not an appropriate workaround. Cross-origin restrictions exist for a reason, and @connect plus a manager’s permission prompt make the request visible, but they do not make every use case appropriate.

For straightforward cases like pulling public data your own account already has legitimate access to, a well-declared @connect and GM_xmlhttpRequest call is a normal and supported pattern, as used by many scripts listed on the HatScripts /userscripts hub.

A second worked example: posting data with headers

Consider a script that submits form data to a webhook at hooks.example.com and needs a custom Authorization header. The metadata block declares @connect hooks.example.com, and the call sets method to POST, includes a headers object with Authorization and Content-Type, and passes the payload through the data option as a JSON string rather than a FormData object, since GM_xmlhttpRequest expects a string body rather than the browser’s native form data types.

The onload callback should check response.status before assuming success, since GM_xmlhttpRequest calls onload for any completed request regardless of status code, unlike some higher level libraries that treat non-2xx responses as errors automatically.

Version behaviour and @connect changes

If a script update adds a new @connect entry that was not present in the previous version, most managers surface this in the update confirmation diff, since it represents new network access the script did not previously have. Treat a newly added @connect domain in an update the same way you would treat it during a fresh install, by checking that it matches the script’s stated purpose before accepting the update.

Mobile-specific considerations

On Safari for iOS and macOS, GM_xmlhttpRequest requests still route through the extension’s background context, but the permission model layers an additional per-site network prompt on top of the install-time review, more visibly than on Chromium-based managers. On Kiwi Browser or Firefox for Android, GM_xmlhttpRequest behaves the same as its desktop counterpart, since both run on the same underlying extension APIs as desktop Chrome and Firefox respectively.

Security review checklist for network-capable scripts

A script that can make cross-origin requests deserves closer review than one that only touches the current page, since it can read and transmit data beyond what is visible on screen.

  • Confirm every @connect domain is one you recognize or can verify belongs to the service the script claims to use
  • Check what data is actually sent in the request body, not just which domain it goes to
  • Watch for a script that sends full page content or cookies to a remote endpoint without a clear stated reason
  • Re-review @connect and the request payload again after any script update, not only at install time

Frequently asked questions

Can I use fetch at all inside a userscript? Yes, for same-origin requests to the page’s own domain, fetch works normally. It is only cross-origin requests that require GM_xmlhttpRequest.

Does GM_xmlhttpRequest bypass CORS entirely? It bypasses the same-origin policy check the browser applies to page-level scripts, because the request runs through the extension’s privileged context rather than the page’s own context. The remote server still receives and can log the request normally.

Why does my request work in Tampermonkey but not Violentmonkey? Check for differences in how each manager handles the headers option or how strictly each enforces @connect, since implementation details between managers can differ slightly even when both follow the same general specification.

Questions about the tools in this guide

Short answers about the hubs this article touches, each linking straight to the tool.

Userscripts

Open hub