Skip to content

Troubleshooting

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.

By 6 min readPublished

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

// 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:

// 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 and the cf_clearance cookie explained; the Cloudflare WAF and 5-second challenge solver page has the same flow in other languages. Automate only sites you are allowed to: see responsible captcha automation.

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

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.

Read next

This article is part of the Cloudflare WAF and 5-second challenge solver hub. Every task is charged only when a token is ready.

Get an API key