The Browserless Turnstile Solver Replays 23 Recorded Browser Answers From One Profile
cfsolve claims to solve Cloudflare Turnstile with no headless browser. Reading its source offline shows it replays recordings of a real browser, and what that means for a site owner.
A library called cfsolve reached the Hacker News front page today with the pitch that it solves Cloudflare Turnstile “without a headless browser”.1 That phrase is doing a lot of work. I read the repository this morning to see what it means in practice, and the short version is that the browser did not disappear. It moved from run time to recording time.
Two limits first. I did not build or run the library, and I sent nothing to any website. Everything below comes from reading the source and the data files it ships with, so I cannot tell you whether it works against Cloudflare today. What I can describe is how it is built, and what a site owner should take from that.
Background: what Turnstile is checking
Turnstile is Cloudflare’s replacement for the old “click all the traffic lights” CAPTCHA. A small widget on your form runs some JavaScript in the visitor’s browser, and if the result looks like a real browser, the widget hands back a token. Your server then forwards that token to Cloudflare’s siteverify endpoint and gets a yes or no.
A headless browser is a normal browser engine (Chrome, say) running without a window, driven by a script. It is the usual way to get past such a widget: let a real engine run the real challenge code. The downside is cost. Every solve needs a whole browser process.
cfsolve takes the other road. Its README says it returns a Turnstile token, or a cf_clearance cookie for the full-page “Just a moment…” challenge, from a Rust program with no browser in the loop.2
What the repository contains
The challenge code that Cloudflare serves is deliberately obfuscated JavaScript. To run it without a browser, you have to do three jobs yourself: read the code, execute its custom instruction set, and answer its questions about the browser it thinks it is running in.
The source folders line up with those jobs. I counted bytes of Rust per folder.3
Show data table
| Kilobytes of Rust source | Source size (KB) |
|---|---|
| stage2 | 522 |
| deobfuscate | 300 |
| vm | 284 |
| challenge | 172 |
| capture | 134 |
| session | 94 |
| embedding_page | 82 |
| http | 61 |
| js | 43 |
Bytes of source are a crude proxy for effort, and folder names are the author’s, not mine. Still, the shape is plain. The part that reads and de-obfuscates the challenge (deobfuscate, built on the swc JavaScript parser) and the part that interprets its virtual machine (vm) are big. The biggest piece is stage2, and its probes subfolder is a list of file names that reads like a browser feature checklist: clock, media, WebSocket echo, worker fetch, ICE, performance, DOM mutation.
The repository also vendors a patched TLS library (BoringSSL, the one Chrome uses) and HTTP stack, about 31 MB on disk, so the network handshake looks like a particular browser’s. The ordering of HTTP headers is stored in the profile too.
None of that is a headless browser. It is a partial reimplementation of the parts of a browser that the challenge asks about.
The 23 recorded answers
Here is the part that changed how I read the project. The README says each browser profile “references recorded responses to the challenge’s browser checks, stored as JSON”, and that producing them takes manual effort, aided by a capture proxy that records what a real browser sends to Cloudflare.2
The one shipped profile is safari-26-ios, Safari on an iPhone. I parsed its data files with Python, as data only.
| File | Size | Probes | Answer fields | Late probes |
|---|---|---|---|---|
safari-26-ios.json (transport, headers, timing) |
17,145 B | n/a | n/a | n/a |
answers.json |
92,624 B | 23 | 105 | 4 |
challenge-page-answers.json |
92,918 B | 23 | 105 | 4 |
A probe here is one numbered question the challenge asks the browser, of the kind the module names suggest: media support, how fast a small task ran, how a rendered image comes out. The answers are recorded values, with a measured duration beside each. The two files have the same shape and the same reference identifier. They also differ in 612 values.
Those 612 differences are the interesting bit. The ones I looked at are timings (a probe took 80 ms in one file and 97 in the other) and what looks like rendered-image data (I am inferring that from the values, the field names are obfuscated), where one pixel run reads 255,255,255,255,184,184,184,254 and the other 255,255,255,255,186,186,186,255, with a different hash on top. That looks like two real recordings of the same kind of device, each with the small natural noise a real browser produces. I did not confirm it was the same physical device, so treat that as a reading, not a fact.
flowchart TD S["ONCE PER PROFILE<br/>Real Safari"] --> P["capture-proxy"] P --> J["profile + answers.json<br/>23 probes, 105 fields"] F["EVERY SOLVE<br/>Fetch challenge page"] --> I["Parse + interpret<br/>challenge program"] I --> R["Fill answers<br/>from the profile"] R --> T["Token or<br/>cf_clearance cookie"] J --> R
The upshot is that “no headless browser” and “no real browser” are different claims. The solver does not need a browser each time, but it does need a real browser once per profile, and that recording is the asset. A challenge that asks a new question, or changes how it checks an old one, breaks the replay until someone records again. The README is honest about this: its error table lists Unsupported for “a new challenge build”. The most recent commit I fetched, from October 29, is titled “fix challenge pages”.
Where the claim gets narrower
Several details in the README put limits on it.
- One profile. Only
safari-26-iosships. The README says Safari and Chrome(ium) profile types are supported, and that making more is work. - Identity must match. The
cf_clearancecookie has to be sent “from the same IP address and user agent as the profile, or Cloudflare will reject it.” - Time is modelled. The profile has
timeline,intervalsandresource_timingsections, and the source carries aperformance_clock.rsthat mimics how Chrome derivesperformance.now(). Reproducing timing that looks human is part of the job, not a side issue.
None of this is a reason to relax. A tool that needs refreshing is still a tool people will refresh. It does change what is worth defending, though.
What a site owner can do with this
Turnstile’s own documentation gives you more than most integrations use.4
- Always validate on the server. The docs say it plainly: “The client-side widget alone does not protect your forms.” A token that your backend never checks is decoration.
- Treat a token as short-lived and single-use. Each token is valid for 300 seconds and can be validated once. A pipeline that solves tokens in advance and stockpiles them runs into that, so do not extend the window on your side.
- Bind the token to the action. The widget accepts an
actionandcDatastring, andsiteverifyreturns them. If your login form usesaction: loginand your signup form usesaction: signup, check the returned value, so a token minted for one is useless for the other. cfsolve’s CLI has--actionand--cdataoptions, which means it can set them, so this raises the bar only if the values are meaningful on your side (a session-boundcData, for example, not a constant). - Check the hostname.
siteverifyreturns the hostname where the challenge was served. Compare it with yours. - Pass the visitor IP. The
remoteipparameter is optional in the docs. Given that the README insists on one consistent IP for clearance cookies, sending it costs nothing.
I did not try any of these against cfsolve, because I did not run it. They come from the Cloudflare documentation, and the connection to this tool is my reading of the README.
This also fits the pattern from my earlier measurement of AI crawlers, where the gap between what a site’s rules say and what its servers enforce was the real story.5 A Turnstile widget with no server-side check is the same gap in a different place.
Trade-offs and what this does not tell you
- I do not know if it works. Not running the code is a deliberate choice. Everything about effectiveness here is unmeasured.
- Source size is not complexity. Byte counts per folder are a rough guide to where the author spent time. A large file can be a lookup table, and a small one can be the clever part.
- My reading of the 612 differences is a guess. I checked a sample, not all of them, and I did not verify where the recordings came from.
- A clean checkout is one snapshot. I fetched the latest commit only, so I cannot say how often the profile has been re-recorded. The commit history would tell you, and I did not pull it.
- Defenders have costs too. Stricter
actionandcDatabinding means more code and more ways to reject a real visitor, such as a user who leaves a form open for ten minutes and hits the 300-second expiry.
What I would take away
- “No headless browser” describes the runtime, not the origin. The solver depends on recordings of a real browser, and the recording is the part that has to be redone when the challenge changes.
- The cost to the attacker shifts to maintenance. One profile, a patched TLS stack and a 47,000-line codebase is a lot to keep current. That is useful information for deciding how much weight to put on Turnstile alone.
- Your server-side check is the control. Validate every token, bind it to an action, and compare hostname and expiry.
- A tool being public is not the same as it being effective today. Read the README’s limits, not only its first sentence.
References
Footnotes
-
Hacker News front page, October 31, 2026 (listed at 206 points when I read it), linking to the repository below. ↩
-
chris124567, cfsolve, README and
src/challenge/orchestrator.rs. I read commit98240dd(October 29, 2026, “fix challenge pages”), fetched with--depth 1, so I have no history. ↩ ↩2 -
cfsolve-profile-stats.py, a script that only reads files. Run with
python -Ion a fresh clone in an otherwise empty directory. The counts quoted in this post come from its output on October 31, 2026. ↩ -
Cloudflare, Turnstile server-side validation. I read it through a summarizing fetch, not the raw page. The 300-second validity, single-use rule, returned
action,cdataandhostnamefields, and optionalremoteipcome from there. ↩ -
robots.txt Allowed 87 AI Requests. The Servers Blocked 25 of Them Anyway., this blog, October 19, 2026. ↩