Tutorial
Cloudflare Turnstile in Chrome Extensions and Content Scripts
Why a Cloudflare Turnstile widget can't live in a Manifest V3 extension page, what to do instead, and how a content script fills a Turnstile form on a page.
By ZeroCaptcha Engineering6 min readPublished
A Cloudflare Turnstile widget cannot run inside a Manifest V3 extension page such as a popup:
Chrome forbids remotely hosted code, so the page may not load Turnstile’s
https://challenges.cloudflare.com/turnstile/v0/api.js, and a Turnstile widget’s hostnames must
be fully qualified domain names, which a chrome-extension:// page is not. Render the widget on a
page of your own website and let the extension open it. A content script, on the other
hand, works on web pages that already show the widget: it shares the page’s DOM, so it can read the
sitekey and write a token into cf-turnstile-response, and it can call the page’s own callback by
injecting a function into the page’s MAIN world.
This tutorial covers both cases: protecting your own extension’s actions, and an extension that fills Cloudflare Turnstile forms on sites you are allowed to automate. Facts are from Chrome’s and Cloudflare’s documentation as checked on 1 October 2026.
Why the widget can’t live in an extension page
Two rules meet here:
- No remote code. Chrome’s policy defines remotely hosted code as “anything that is executed by
the browser that is loaded from someplace other than the extension’s own files”. Extension pages
run under
script-src 'self', and “Regardless of where the code comes from, it is not allowed to have RHC.” Turnstile’sapi.jsis exactly that. - Domain names only. A Turnstile widget’s allowed hostnames must be “fully qualified domain
names (FQDNs):
example.comorsubdomain.example.com”, with no scheme, port, path or wildcard. An extension page’s origin,chrome-extension://plus the extension’s ID, is none of those.
Protecting your own extension’s actions
If your extension talks to your own backend and you want Turnstile in front of an action, such as sign-up:
- Host the page. Put the form with the widget on your website, say
https://app.example.com/extension/signup, and add that hostname to the widget. - Open it from the extension with
chrome.tabs.create({ url }). The page runs the widget, posts the form to your backend, and your backend checks the token with siteverify, as for any web form. - Tell the extension the result. Let the page send a message to the extension (Chrome’s
externally_connectablemanifest key names which sites may), or have the extension ask your backend.
Chrome’s policy also allows “Sandboxed iframes containing remote pages”, but a hosted page in its own tab is the simplest setup that stays inside both rules. And ask whether an extension that is already signed in needs a human check at all: an authenticated API call with a per-user token often fits better.
Filling a Cloudflare Turnstile form from a content script
For sites you are allowed to automate, an extension can fill Turnstile forms: the content script finds the widget, the service worker gets a token, and a function injected into the page puts it where the widget would.
Three Chrome rules shape the design:
- Isolated worlds. A content script runs in “a private execution environment that isn’t
accessible to the page or other extensions”, but “they share access to the page’s DOM”. It can
read
data-sitekeyand write the hidden field, not call the page’s functions. - Network requests. “Content scripts initiate requests on behalf of the web origin that the content script has been injected into”, so API calls belong in the service worker, which “can talk to remote servers outside of its origin, as long as the extension requests host permissions”.
- The MAIN world.
chrome.scripting.executeScriptwithworld: "MAIN"runs a function in “the execution environment shared with the host page’s JavaScript”, which is where the page’s callback lives. It needs thescriptingpermission and host permissions for the page.
The manifest:
{ "manifest_version": 3, "name": "Cloudflare Turnstile form helper", "version": "1.0.0", "permissions": ["scripting", "storage"], "host_permissions": ["https://shop.example.com/*", "https://api.zerocaptcha.io/*"], "background": { "service_worker": "service-worker.js" }, "content_scripts": [{ "matches": ["https://shop.example.com/*"], "js": ["content.js"] }]}Replace https://api.zerocaptcha.io/* with your ZeroCaptcha API address, or better, with your
own backend (see the key warning below).
The content script, content.js, reads the widget and asks the service worker to solve it:
const widget = document.querySelector("form#search [data-sitekey]");if (widget) { chrome.runtime .sendMessage({ type: "solve-turnstile", widget: { websiteURL: location.href, websiteKey: widget.dataset.sitekey, // Many sites check the widget's action and cData when they verify the token. action: widget.dataset.action, cdata: widget.dataset.cdata, callback: widget.dataset.callback, }, }) .then((reply) => console.log("Cloudflare Turnstile token:", reply.ok ? "filled" : reply.error));}The service worker, service-worker.js, creates the task, polls it, and fills the page:
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function call(method, body, headers = {}) { // Reading storage is an extension API call, which also resets the service worker's idle timer. const { api, clientKey } = await chrome.storage.local.get(["api", "clientKey"]); const response = await fetch(`${api}/${method}`, { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify({ clientKey, ...body }), }); const reply = await response.json(); if (reply.errorId) throw new Error(`${method}: ${reply.errorCode}`); return reply;}
async function solveTurnstile({ websiteURL, websiteKey, action, cdata }) { // The widget's data-action and data-cdata go in metadata, only when it sets them. const task = { type: "TurnstileTaskProxyless", websiteURL, websiteKey, metadata: {} }; if (action) task.metadata.action = action; if (cdata) task.metadata.cdata = cdata; // One Idempotency-Key per task: a retried create with it returns the same task. const { taskId } = await call("createTask", { task }, { "Idempotency-Key": crypto.randomUUID() }); for (let polls = 0; polls < 90; polls += 1) { await sleep(2_000); const result = await call("getTaskResult", { taskId }); if (result.status === "ready") return result.solution.token; } throw new Error(`task ${taskId}: no token within 180 seconds`);}
function fillToken(token, callbackName) { for (const input of document.querySelectorAll('[name="cf-turnstile-response"]')) input.value = token; const callback = callbackName && window[callbackName]; if (typeof callback === "function") callback(token);}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type !== "solve-turnstile") return false; solveTurnstile(message.widget) .then((token) => chrome.scripting.executeScript({ target: { tabId: sender.tab.id, frameIds: [sender.frameId] }, world: "MAIN", func: fillToken, args: [token, message.widget.callback ?? null], }), ) .then(() => sendResponse({ ok: true })) .catch((error) => sendResponse({ ok: false, error: String(error) })); return true; // the reply is sent later});fillToken is passed to executeScript as a function, so Chrome runs a copy of it in the page:
it may only use its arguments, not the service worker’s variables.
Service worker lifetime
Chrome stops an extension service worker “After 30 seconds of inactivity”, and “Receiving an event
or calling an extension API resets this timer.” A solve takes seconds, sometimes longer, so each
poll above reads chrome.storage, an extension API call. A single event may not take “longer than 5
minutes to process”, and the three-minute polling limit stays well under it.
Keep the key out of anything you publish
The code reads the API key from chrome.storage.local, which suits an extension you load unpacked
for your own work. Never publish an extension with a key in it: anyone who installs it can read
the key and spend your balance. For an extension other people use, point api at your own backend,
which holds the key and forwards the task.
ZeroCaptcha keys can be pinned to your server’s IP addresses,
and each key can have its own daily spend cap;
see secure captcha API keys.
Why a solving API rather than an extension that clicks widgets itself is in captcha solver extensions vs a solving API, and the same flow in scripts is on the Cloudflare Turnstile solver page.
Sources
- Chrome for Developers: remotely hosted code (checked 1 October 2026).
- Chrome for Developers: content scripts (checked 1 October 2026).
- Chrome for Developers: cross-origin network requests (checked 1 October 2026).
- Chrome for Developers: chrome.scripting (checked 1 October 2026).
- Chrome for Developers: the extension service worker lifecycle (checked 1 October 2026).
- Cloudflare Turnstile: hostname management (checked 1 October 2026).
The team that builds and runs the ZeroCaptcha API. Articles are drafted with AI tools, then checked against the API's code and the primary sources each one cites.