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 ZeroCaptcha Engineering6 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
- Electron: net and ClientRequest (checked 1 October 2026).
- Electron: session and
app, for
setProxy,setUserAgent,userAgentFallbackand theloginevent (checked 1 October 2026). - Electron issue 19600, on the app name and version in Electron’s user agent (checked 1 October 2026).
- Cloudflare Turnstile: mobile implementation, Cloudflare’s guide for embedded browsers (checked 1 October 2026).
- Cloudflare Turnstile: hostname management (checked 1 October 2026).
- Cloudflare challenges: clearance (checked 1 October 2026).
- Cloudflare challenges: detect a challenge response (checked 1 October 2026).
- Cloudflare: Error 403 (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.