Skip to content

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 6 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’s api.js is exactly that.
  • Domain names only. A Turnstile widget’s allowed hostnames must be “fully qualified domain names (FQDNs): example.com or subdomain.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:

  1. 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.
  2. 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.
  3. Tell the extension the result. Let the page send a message to the extension (Chrome’s externally_connectable manifest 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-sitekey and 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.executeScript with world: "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 the scripting permission 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

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.

Questions

Can I show a Cloudflare Turnstile widget in my Chrome extension's popup?

Not directly. Manifest V3 forbids remotely hosted code, so an extension page cannot load Turnstile's api.js from challenges.cloudflare.com, and a Turnstile widget's hostnames must be domain names, which a chrome-extension:// page is not. Render the widget on a page of your own website instead.

Can a content script call the page's Cloudflare Turnstile callback?

Not from its default isolated world, which cannot see the page's JavaScript. It shares the page's DOM, so it can fill the cf-turnstile-response field; to call the page's callback, inject a function into the MAIN world with chrome.scripting.executeScript.

Should a published extension contain a solving API key?

No. Anyone who installs the extension can read the key and spend your balance. Keep the key on your own server, and let the extension call that server.

Read next

This article is part of the Cloudflare Turnstile solver hub. Every task is charged only when a token is ready.

Get an API key