FCaptcha v1.12: Catch AI Agents in Real Browsers
How FCaptcha v1.11 and v1.12 detect AI agents that drive real browsers, using CDP input forensics, think-time cadence, and declared-agent matching.
bot-detectionTwo FCaptcha releases found the same flaw: correct detections thrown away by a weighted sum. How preconditions and a corroboration floor fixed it.
WebDecoy Team
WebDecoy Security Team
A bot detector can fire correctly, on every signal it has, and still hand the bot a valid token.
FCaptcha shipped two releases this month that found exactly that, twice, for the same underlying reason. Neither was a detection failure. In both cases the detectors did their job and the arithmetic that combined them threw the answer away.
This is a worked example of a failure mode that any scored detection system can have, including ones that are not FCaptcha, so it is worth walking through the numbers rather than just the changelog.
v1.23.0 fixed this:
curl -X POST /api/verify -d '{"siteKey":"x","signals":{}}'No browser. No proof of work. An empty signals object. That request minted a valid token on every server, and the token passed siteverify. The behaviour was present in v1.21.0 and earlier.
The detectors were not at fault. They fired. The problem was that the final verdict was a weighted sum, and in a weighted sum a category can contribute at most its own weight. The bot category is weighted 0.13. So every proof-of-work failure firing simultaneously reached a combined 0.1298, against a threshold of 0.5.
A forged solution referencing a challenge that was never issued did better and still lost: seven detections, final score 0.4003. Correct verdict, insufficient arithmetic.
The fix was to stop asking the score a question it structurally could not answer:
success = score < 0.5 && hostnameAllowed && powSatisfiedProof of work moved outside the score entirely, alongside the hostname allowlist, as a precondition. Missing, unverifiable and nonce-unbound solutions are now marked dispositive and report 0.9 rather than 0.4, and refusals name the failed precondition in a new reason field, either pow_not_satisfied or hostname_not_allowed.
Impact on legitimate traffic: none measured.
v1.27.0, shipped today, is the same shape of bug reached from the opposite direction.
A source-patched browser scrubs every JavaScript-observable automation flag at the binary level. It trips no headless signal, no CDP signal, no fingerprint, datacenter or bot category. Nothing environmental gives it away, because there is nothing left to observe.
In a weighted sum, all of those untouched category budgets still count as clean. That left such a browser with a ceiling of 0.41 against a 0.5 threshold. Not a high bar to clear. An unreachable one. It could not be blocked however obviously robotic its movement was.
The corpus sample of that adversary tripped seven correct behavioural detections:
Final score: 0.234. Allowed, token issued. Every detector fired and the aggregation discarded the verdict.
The score is now floored at 0.6 when two or more behavioural categories independently reach 0.5.
The reasoning is worth stating carefully, because it is not the same as raising a weight. Corroboration across independent views is evidence in its own right, distinct from any single view being strong. Three separate ways of looking at mouse movement agreeing that something is wrong means more than one of them being slightly more confident. That sample now scores 0.600, recommendation block, no token, on all three servers.
This is the part that makes the change trustworthy. The thresholds were measured against a labelled corpus over a 40-point grid rather than picked by intuition:
| Threshold | Humans reaching 2+ categories | Agents reaching 2+ categories |
|---|---|---|
| 0.30 | 0 of 126 | 75 of 75 |
| 0.50 | 0 of 126 | 66 of 75 |
| 0.70 | 0 of 126 | 47 of 75 |
No human in the 126-sample panel reached two agreeing behavioural categories at any threshold tested. Requiring three categories fails outright: all sixteen such combinations leave the adversary allowed.
Human panel median stayed at 0.088 and maximum at 0.130, both unchanged, with a benchmark false-positive rate of 0.00%.
The adversary above is one synthetic, hand-authored corpus sample. It shows the arithmetic works on the shape the corpus describes. It does not show that it works against a real source-patched browser in the wild.
That distinction matters more than it might seem. A detection claim validated only against a sample you wrote yourself is a hypothesis with good arithmetic behind it, not a field result. Captured traces from real automation browsers remain the highest-value contribution anyone can make to the project.
Both bugs share a root cause: a weighted sum caps how much any one thing can matter.
That property is desirable most of the time. It is what stops a single noisy signal from blocking a real person. But it also means a weighted sum cannot express two things that detection genuinely needs:
If you run any scored detection system, the diagnostic question is worth borrowing: what is the highest score an adversary can reach if it defeats every category except one? If that ceiling sits under your threshold, you have a class of attacker you cannot block regardless of how well your detectors work.
Fifteen releases landed between v1.13.0 and v1.27.0. The ones worth knowing about:
| Release | Change |
|---|---|
| v1.14.0 | Stealth-patch artifact detection. Catches cloud AI agents driving patched Chromium via tostring_proxied and an impossible permission state, rather than environment heuristics that misfire on real Linux and VPN users |
| v1.15.0 | Web Bot Auth upgraded from a presence-only header check to real cryptographic verification (RFC 9421). Only a genuine crypto failure counts as forged, so an unreachable key directory never accuses |
| v1.19.0 | Native JA4-TLS computed from the ClientHello when the Go server terminates TLS, instead of trusting a reverse-proxy header |
| v1.20.0 | Two signals dropped from scoring because neither actually indicates automation. If you tuned custom thresholds, re-check them, legitimate visitors now score lower |
| v1.21.0 | Proof-of-work cost scales with recent suspicion, on wall-clock rather than hash difficulty. Difficulty punishes slow phones and barely inconveniences native solvers; nobody can make less time pass |
| v1.22.0 | Serves the Turnstile, reCAPTCHA and hCaptcha siteverify contract, so an existing backend works by changing the base URL |
| v1.24.0 | 34 languages with RTL layout, and a real accessibility fix: the checkbox previously had no accessible name at all |
| v1.25.0 | Widget published to npm and a CDN with Subresource Integrity. Self-hosting remains the default |
| v1.26.0 | A Helm chart that actually exists, after the ArtifactHub listing pointed at a missing one for months |
Two of those deserve a second look before you upgrade.
v1.22.0 contains a breaking change. POST /api/token/verify now requires secret. It previously accepted and ignored one, which meant any caller who could reach the endpoint could spend a token. FCAPTCHA_LEGACY_UNAUTH_VERIFY=true restores the old behaviour for exactly one release.
v1.20.0 will move your scores. Two signals that were reported as Playwright artifacts, webdriver_configurable and chrome_runtime_missing, turned out to indicate nothing about automation. Removing them means legitimate visitors score lower than they used to, so any hand-tuned threshold needs re-checking.
FCaptcha is open source and self-hosted by default, so no data leaves your infrastructure. If you are on v1.21.0 or earlier, v1.23.0 is a security fix and you want it, since tokens could be minted with no proof of work at all.
WebDecoy’s own embedded client tracks the release tag and is on 1.27.0. If you want the background on the behavioural signals underneath all of this, v1.12 covered CDP input-event forensics and think-time cadence, and the keystroke cadence work goes back further still.
A request carrying no proof of work at all was issued a valid token. A single curl POST with an empty signals object minted tokens on every server, and those tokens passed siteverify. The detectors were not at fault, they fired correctly. The final score was a weighted sum in which the bot category contributed at most 0.13, so every proof-of-work failure firing at once reached 0.1298 against a threshold of 0.5. The fix moves proof of work outside the score entirely, as a precondition alongside the hostname allowlist.
It is a rule that raises the final score when several independent detector categories agree, rather than waiting for any single one to be strong enough. FCaptcha v1.27.0 floors the score at 0.6 when two or more behavioural categories independently reach 0.5. The reasoning is that agreement between independent views is itself evidence, separate from the strength of any one view.
Because it scrubbed every JavaScript-observable automation flag at the binary level, it tripped no headless, CDP, fingerprint, datacenter or bot signals. In a weighted sum, those unused category budgets were still counted as clean, which capped the achievable score at 0.41 against a 0.5 threshold. The browser could not be blocked no matter how obviously robotic its mouse movement was, even though seven behavioural detections fired correctly.
Not in the measured panel. The constants were swept over a 40-point grid against a labelled corpus rather than chosen by hand. No human in the 126-sample panel reached two agreeing behavioural categories at any threshold tested, while 66 of 75 agents did at the chosen threshold of 0.5. Human panel median stayed at 0.088 and maximum at 0.130, unchanged, with a benchmark false-positive rate of 0.00%.
Since v1.22.0 it serves the same siteverify contract as Turnstile, reCAPTCHA and hCaptcha, including the response shape and error-code vocabulary, so an existing backend integration can point at FCaptcha by changing the base URL. Note one breaking change in the same release: the older /api/token/verify endpoint now requires a secret, because it previously accepted and ignored one, meaning any caller who could reach it could spend a token.
No, and the project says so directly. The adversary that motivated the change is a single synthetic, hand-authored corpus sample. It demonstrates that the arithmetic works on the shape the corpus describes, not that it defeats a real source-patched browser in the wild. Captured traces from real automation browsers remain the most valuable contribution anyone can make to the project.
How FCaptcha v1.11 and v1.12 detect AI agents that drive real browsers, using CDP input forensics, think-time cadence, and declared-agent matching.
bot-detectionFCaptcha v1.3 adds 7 biometric keystroke metrics, Playwright detection, AI agent bypass fixes, and server-side PoW validation.
bot-detectionOpen source CAPTCHA with 40+ behavioral signals, proof of work, and vision AI detection. Self-hosted servers in Go, Python, Node.js.
bot-detectionLike this post? Share it with your friends!
Get a personalized demo from our team.