# 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.

- Source: https://zerocaptcha.io/blog/cloudflare-turnstile-chrome-extension
- Published: 2026-10-01
- Author: ZeroCaptcha Engineering

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:

```json
{
  "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:

```js
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:

```js
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](https://zerocaptcha.io/guides/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](https://zerocaptcha.io/blog/captcha-solver-extension-vs-api), and the same
flow in scripts is on the [Cloudflare Turnstile solver](https://zerocaptcha.io/cloudflare-turnstile-solver) page.

## Sources

- [Chrome for Developers: remotely hosted code](https://developer.chrome.com/docs/extensions/develop/migrate/remote-hosted-code)
  (checked 1 October 2026).
- [Chrome for Developers: content scripts](https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts)
  (checked 1 October 2026).
- [Chrome for Developers: cross-origin network requests](https://developer.chrome.com/docs/extensions/develop/concepts/network-requests)
  (checked 1 October 2026).
- [Chrome for Developers: chrome.scripting](https://developer.chrome.com/docs/extensions/reference/api/scripting)
  (checked 1 October 2026).
- [Chrome for Developers: the extension service worker lifecycle](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle)
  (checked 1 October 2026).
- [Cloudflare Turnstile: hostname management](https://developers.cloudflare.com/turnstile/additional-configuration/hostname-management/)
  (checked 1 October 2026).

## 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.
