# Cloudflare Challenges in Electron and Desktop Apps: Fixes

> Why an Electron app's requests hit Cloudflare challenge pages, and the fixes: Chromium's network stack, one session, a steady user agent, and cf_clearance.

- Source: https://zerocaptcha.io/blog/electron-cloudflare-challenge
- Published: 2026-10-01
- Author: ZeroCaptcha Engineering

An Electron app meets Cloudflare challenge pages for one of three reasons: its **main process calls
the site with Node's HTTP stack**, which runs no JavaScript and does not look like a browser; its
**user agent changes** between the challenge and later requests; or its **cookies live in a
different session** from the one that passed. The fixes follow: make requests with Electron's `net`
module or `session.fetch()`, which use "Chromium's network stack", in the same session as the window
that passed the challenge, with `credentials: "include"` and one steady user agent. A Cloudflare
Turnstile widget in your own app must also load from a real domain, never from `file://`.

This article walks through each cause, shows the main-process code, and covers apps that need a
`cf_clearance` cookie without a person at the window. Facts are from Electron's and Cloudflare's
documentation as checked on 1 October 2026.

## Where challenges come from in a desktop app

| Where the request comes from | Runs the challenge? | Looks like | Typical result |
| --- | --- | --- | --- |
| A `BrowserWindow` or `WebContentsView` | Yes, it is Chromium | Chrome, plus whatever your user agent says | Passes like a browser, if the user agent stays the same |
| `net.fetch()` or `session.fetch()` in the main process | No, but it carries the session's cookies | Chromium's network stack | Works once the session holds a valid `cf_clearance` |
| Node's `http`, `https`, `fetch` or a Node library | No | Node's TLS stack | A challenge page: HTTP 403 with `cf-mitigated: challenge` |

Electron's docs list what its `net` module gains from Chromium, such as "Automatic management of
system proxy configuration" and "Automatic tunneling of HTTPS requests"; the part that matters here
is that its requests go through the same stack as the window. Node's HTTP stack has a different TLS
handshake altogether, which is why [TLS fingerprints](https://zerocaptcha.io/blog/tls-fingerprint-ja4-cloudflare) tell it
apart from Chrome whatever user agent it sends.

## Fix 1: make main-process requests through the window's session

`net.fetch()` uses the default session; `ses.fetch()` uses another. Either sends the session's
cookies only when asked: Electron's request documentation says that without the `credentials`
option, "authentication data from the session will be sent, and cookies will not be sent", and that
with `include`, "credentials from the session associated with the request will be used".

```js
// main.mjs: the person passes any challenge in the window; the main process then reuses it.
import { BrowserWindow, app, net } from "electron";

app.whenReady().then(async () => {
  const window = new BrowserWindow({ width: 1100, height: 800 });
  await window.loadURL("https://shop.example.com/");

  // Later, from the main process, through the same (default) session and its cookies:
  const response = await net.fetch("https://shop.example.com/api/items", {
    credentials: "include",
  });
  if (response.headers.get("cf-mitigated") === "challenge") {
    console.log("Challenged: load the page in the window again, then retry.");
  } else {
    console.log(response.status, (await response.text()).slice(0, 200));
  }
});
```

## Fix 2: set one user agent, before anything loads

Cloudflare's guide for embedded browsers is direct: "Changing the User Agent during a session
causes Turnstile challenges to fail", and it requires a "Consistent User Agent throughout the
session".
A `cf_clearance` cookie is also tied to "the specific visitor and device it was issued to", in
Cloudflare's words, which in practice means the user agent that earned it. In Electron, the user agent
can be set at three levels: `webContents`, `session` (with `ses.setUserAgent()`), and
`app.userAgentFallback`, "the user agent that will be used when no user agent is set at the
`webContents` or `session` level". Pick one level, set it at start-up, and never change it while the
app runs. Electron's default user agent also names your app and Electron, which Electron's own issue
tracker shows; print `session.defaultSession.getUserAgent()` to see what yours sends.

## Fix 3: one session per identity

Cookies live in a session. A window created with its own `partition` has its own cookie jar, so a
clearance earned there is not in the default session your `net.fetch()` uses. Keep the window and
the requests in one session, or copy the cookie across with `ses.cookies`. Keep each session on
one network route too: in practice, a `cf_clearance` cookie works only from the IP address that
earned it.

## Cloudflare Turnstile in your own Electron app

If your app shows your own form with a Cloudflare Turnstile widget, load that page from your website
(`https://app.example.com/…`), not from a file in the app bundle. A widget's hostnames must be
"fully qualified domain names", with no scheme, port or path, so a `file://` or custom-protocol page
has no hostname to add. Cloudflare's embedded-browser guide also asks for JavaScript, DOM storage and
access to `challenges.cloudflare.com`, and warns that a strict Content Security Policy can block the
widget. The token is then checked by your server with siteverify, as on the web.

## When no one is at the window

Some desktop tools fetch data on their own, from sites their users are allowed to automate, with no
person to pass a challenge. They need a `cf_clearance` cookie earned through the same proxy they will
use, and the user agent it was issued for.
ZeroCaptcha's challenge task does that: it passes the page through your proxy and returns the cookie and its user agent.
In Electron, put both into a session that uses the same proxy:

```js
// clearance.mjs: a session with its own proxy, clearance and user agent.
import { app, session } from "electron";

const API = process.env.ZEROCAPTCHA_API;
const headers = { Authorization: `Bearer ${process.env.ZEROCAPTCHA_KEY}` };
const PROXY = { host: "proxy.example.net:8080", user: process.env.PROXY_USER, pass: process.env.PROXY_PASS };
const SITE = "https://shop.example.com/";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function solveChallenge() {
  let task = await fetch(`${API}/v1/tasks`, {
    method: "POST",
    headers: { ...headers, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
    body: JSON.stringify({
      type: "CloudflareChallengeTask",
      websiteURL: SITE,
      proxy: `http://${PROXY.user}:${PROXY.pass}@${PROXY.host}`,
    }),
  }).then((response) => response.json());
  while (task.status === "queued" || task.status === "running") {
    await sleep(2_000);
    task = await fetch(`${API}/v1/tasks/${task.id}`, { headers }).then((response) => response.json());
  }
  if (!task.solution) throw new Error(`${task.errorCode}: ${task.errorDescription}`);
  return task.solution; // { token, userAgent, cookie: { name: "cf_clearance", value, expiresAt } }
}

app.on("login", (event, webContents, details, authInfo, callback) => {
  if (authInfo.isProxy) {
    event.preventDefault();
    callback(PROXY.user, PROXY.pass);
  }
});

app.whenReady().then(async () => {
  const ses = session.fromPartition("persist:shop");
  await ses.setProxy({ proxyRules: PROXY.host });
  const { userAgent, cookie } = await solveChallenge();
  ses.setUserAgent(userAgent);
  await ses.cookies.set({ url: SITE, name: cookie.name, value: cookie.value, secure: true, sameSite: "no_restriction" });

  const response = await ses.fetch(SITE, { credentials: "include" });
  console.log(response.status, response.headers.get("cf-mitigated") ?? "not challenged");
});
```

The task call uses Node's `fetch` on purpose: it talks to the API, not to the site. Every request to
the site goes through the session, its proxy, its user agent and its cookie. The API serves the
clearance for 30 minutes; the site's Challenge Passage decides how long it really works. When the
site challenges again, solve again. The details are in [Cloudflare WAF and 5-second challenges](https://zerocaptcha.io/docs/challenges) and
[the cf_clearance cookie explained](https://zerocaptcha.io/guides/cf-clearance-cookie-explained); the
[Cloudflare WAF and 5-second challenge solver](https://zerocaptcha.io/cloudflare-challenge-solver) page has the same flow in other
languages. Automate only sites you are allowed to: see
[responsible captcha automation](https://zerocaptcha.io/guides/responsible-captcha-automation).

## Sources

- [Electron: net](https://www.electronjs.org/docs/latest/api/net) and
  [ClientRequest](https://www.electronjs.org/docs/latest/api/client-request) (checked 1 October 2026).
- [Electron: session](https://www.electronjs.org/docs/latest/api/session) and
  [app](https://www.electronjs.org/docs/latest/api/app), for `setProxy`, `setUserAgent`,
  `userAgentFallback` and the `login` event (checked 1 October 2026).
- [Electron issue 19600](https://github.com/electron/electron/issues/19600), on the app name and
  version in Electron's user agent (checked 1 October 2026).
- [Cloudflare Turnstile: mobile implementation](https://developers.cloudflare.com/turnstile/get-started/mobile-implementation/),
  Cloudflare's guide for embedded browsers (checked 1 October 2026).
- [Cloudflare Turnstile: hostname management](https://developers.cloudflare.com/turnstile/additional-configuration/hostname-management/)
  (checked 1 October 2026).
- [Cloudflare challenges: clearance](https://developers.cloudflare.com/cloudflare-challenges/concepts/clearance/)
  (checked 1 October 2026).
- [Cloudflare challenges: detect a challenge response](https://developers.cloudflare.com/cloudflare-challenges/challenge-types/challenge-pages/detect-response/)
  (checked 1 October 2026).
- [Cloudflare: Error 403](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/4xx-client-error/error-403/)
  (checked 1 October 2026).

## Questions

### Why does my Electron app get a Cloudflare challenge in the main process but not in the window?

The window is Chromium: it runs the challenge's JavaScript and sends Chrome's TLS handshake. Requests made with Node's http module or a Node library run no JavaScript and look like Node, so Cloudflare answers them with a challenge page. Use Electron's net module or session.fetch, which use Chromium's network stack, with the session that passed.

### Can I use Cloudflare Turnstile on a page loaded from file:// in Electron?

No. A Turnstile widget only runs on the hostnames added to it, which must be domain names. Load the form from your website over HTTPS instead.

### Why does the challenge fail after I change the user agent?

Cloudflare says changing the user agent during a session causes Turnstile challenges to fail, and it ties a cf_clearance cookie to the visitor and device that earned it, which in practice means the user agent it was issued for. Set one user agent before loading any page and keep it.
