Bot detection has two failure modes, and they do not get equal attention. Letting a bot through is the one that makes changelogs and headlines — last month’s post was exactly that story, twice. Blocking a person is quieter. The visitor who gets refused does not file a bug report. They close the tab.

FCaptcha shipped seven minor releases between August 20 and August 30 — v1.28 through v1.34.1 — and the striking thing about reading them back to back is how much of the changelog is the second failure mode. Expired tabs, slow form-fillers, screen-reader users, invisible-mode visitors who were being scored on signals that only exist when there is a widget. This post walks through the ones worth understanding, because most of them generalize beyond FCaptcha.

There is also one breaking change and a genuine scaling milestone. Those are covered too.

The five-minute problem, three times

FCaptcha holds a proof-of-work challenge for five minutes, and its tokens live for 300 seconds. Three separate releases fixed three separate ways those two constants punished a real person.

v1.28.1: a failed proof of work floored the score. v1.23.0 correctly made the proof of work a precondition — no valid proof, no token — but also marked its failure dispositive, which floors the score at 0.9. That turned every benign cause of a proof-of-work failure into a hard block on a real visitor:

  • the challenge expires after five minutes, which a tab left open exceeds
  • challenges lived only in memory, so every server restart invalidated the outstanding ones
  • a double-click replays a solution

An observed verdict had a weighted sum of 0.2324 — an entirely ordinary human — reported as 0.9, blocked. The security requirement was already carried by the gate: a token is withheld whenever there is no valid proof of work, whatever the score says. The floor added nothing to that. It only mislabelled the visitor. It is now removed from all three proof-of-work failures, and the measured results afterward tell the story cleanly:

human-shaped, challenge expired   0.155  allow   token withheld
bare curl, no PoW, no signals     0.600  block   token withheld
honest full handshake             0.044  allow   token ISSUED

A bare curl is still caught — on its own behavioural evidence rather than on the floor.

v1.34.0: the widget outlived its own token. Servers reject a token older than 300 seconds. The widget did not know that, so the checkbox went on showing “verified” long past the point the token would be accepted, and a form left open for five minutes failed on submit with no way for the integrator to know why. The fix has a detail worth appreciating: expiredCallback had been in the public options object since the first release and was invoked nowhere. It now actually fires — the widget resets to unverified, drops the stale token so getToken() stops handing back something the server will refuse, and expires slightly before the server-side lifetime so a submit right on the boundary is never refused by a token the checkbox still showed as good.

v1.34.1: the challenge expired too. The widget fetched its proof-of-work challenge at load and never replaced it. A form filled in slowly — or a tab returned to later — submitted a solution to a challenge the server no longer held, and the visitor saw “Verification failed” for doing nothing more suspicious than taking their time. Worse, the failure path left the spent challenge in place, so clicking again repeated the refusal until the page was reloaded. The widget now replaces a challenge within a 15-second margin of its expiry, and fetches a fresh one after a failed attempt so the retry has something to work with.

The shared lesson: every lifetime constant needs an owner on the client side. A tab left open for five minutes is not an edge case. It is a person filling in a long form, which is precisely the person the checkbox exists to let through.

Evidence that was never there

v1.31.0 fixed a subtler class of false positive: signals that structurally cannot exist being read as evidence against the visitor.

Invisible mode never renders a widget, so it never runs the client’s click analysis — yet the servers scored invisible-mode visitors on approach path, exploration ratio and overshoot corrections, reading the absent values as suspicious. And interaction duration means time on page in invisible mode rather than time spent solving, so ordinary reading tripped a signal meant to catch captcha farms. All four signals are now gated on the interaction mode, which the server establishes from the endpoint it was called on rather than from anything the client claims.

The same release ported the accessibility exemptions to the Node library, which had been scoring touch, keyboard-only and screen-reader visitors on mouse-trajectory checks that cannot apply to them.

v1.33.2 closed a related path: a short genuine mouse trace — nine points — could be reported as no movement, false zeros where sparse-but-real data existed. Under-sampled derived metrics now stay neutral instead of supporting behavioural conclusions the data cannot carry. The release notes keep the project’s usual honesty about limits: automated browser coverage verifies payload integrity, and human acceptance still requires validation with real-user captures.

The verification bug that only bit in production

v1.30.0 is the release to read if you integrate FCaptcha server-side, because the bug it fixes was invisible in development and near-universal in deployment.

POST /api/token/verify checked the token against the socket address of whoever called the endpoint. But a token is minted for a browser visitor, and the verification call comes from your backend. Those are the same host on a developer’s laptop and almost nowhere else. Everywhere else, the check compared a visitor’s address against a server’s and rejected a perfectly good token.

Verification now follows Siteverify semantics: supply remoteip and the token is checked against that address — the visitor IP from the original browser request — or omit it and no IP check is performed. The caller’s own socket address is never used. IP binding is opt-in rather than implicit and wrong, and if you worked around the old behaviour by co-locating verification with your visitor-facing edge, you can stop.

The same release added a cross-server conformance suite that runs one shared contract against the Go, Node and Python production containers in CI — single-use tokens, proof-of-work enforcement, verification authentication, Siteverify shape. It deliberately does not compare detector scores, where the three servers are documented to differ. It pins the places where a difference would be a security defect.

The library was a second, worse implementation

Two releases in this window are about the same organizational failure, and it is one worth borrowing as a warning: require('@webdecoy/fcaptcha') and the bundled server had drifted into two separate implementations, and the one nobody benchmarked drifted down.

v1.28.2: the exported ScoringEngine could issue a token without a valid proof of work. The HTTP servers enforce the proof as a precondition; the library counted its absence as weighted evidence — and that evidence alone stays below the allow threshold. A request carrying no proof at all could be issued a token. Deployments running server.js, Docker or Helm were never affected; direct library integrations were, on every version through 1.28.1.

v1.31.0: the library was also scoring with an older engine — a reduced detector set and the confidence-weighted-mean aggregation that v1.18.0 had replaced. The practical effect: an automated browser could receive a passing verdict from the library that server.js would have refused.

Both entry points now run one shared detection core, and a version test pins the release version across every file that carries it so the drift cannot recur silently at release time. If you integrate the library directly, re-check your thresholds — the same traffic now scores higher, which is the fix working, but it moves where your allow, challenge and block boundaries land.

The v1.27 post’s lesson was that arithmetic can discard correct detections. This one is its organizational sibling: two implementations of one contract will diverge, and they will diverge in whichever one your CI is not watching.

The breaking change: no more free secret

v1.29.0 changes startup behaviour, and it is the release to plan around before upgrading.

A signing secret is now required to start. Go, Node and Python all fail closed when FCAPTCHA_SECRET is absent — or still set to the public development key, which is published in the repository and therefore lets anyone who has read the source mint valid tokens. Zero-configuration startup used to succeed with that key; that convenience was a forgery vector.

If you already set your own secret, nothing changes. If you did not, the server refuses to boot and tells you why. Set a real secret for anything reachable from a network — tokens issued under the old default should be treated as forgeable, and rotating the secret invalidates them, which is the point. For local-only development, FCAPTCHA_INSECURE_DEV_MODE=1 restores the old behaviour with a warning on every boot.

The same release bounded public request bodies at 64 KiB across all three servers, rejected before JSON or form parsing rather than after. The ceiling was measured, not guessed: committed browser traces run 10–15 KiB, so the limit leaves four times the observed maximum while bounding what an unauthenticated caller can make the server do.

Scaling out without silently degrading

v1.32.0 and v1.33.0 together end FCaptcha’s single-instance-only era, Go first and then Node and Python.

The state FCaptcha’s security properties depend on — proof-of-work challenges, token replay protection, Siteverify idempotency, rate limits, suspicion history — lived in process memory. A second replica meant a second, independent set of tables: a challenge issued by one pod was unknown to the next, and “single-use” was only ever single-use per pod. Setting REDIS_URL moves that state into Redis, with challenge and token consumption atomic, so single-use means single-use across the whole deployment.

The design decision worth noticing is that both failure modes are closed, not open. A server refuses to start if its configured Redis is unreachable, and fails closed if Redis drops out at runtime — because a distributed-state backend that silently reverts to local state on failure gives you the appearance of shared enforcement and none of the substance. The Helm chart enforces the pairing too: rendering more than one replica without Redis configured fails with an explanation instead of deploying pods that disagree with each other.

And because a single-process test cannot observe any of this, CI now starts two containers of each server against one Redis and proves the cross-replica properties directly: a challenge issued by one replica verifies on the other, replay is rejected across replicas, an idempotency response created on one is returned by the other.

The accessibility release

v1.28.0 brought the widget to WCAG 2.2 AA — which it was already described as meeting, and an audit found it did not. Seven contrast failures, the worst being the checkbox border at 1.72:1 against a required 3:1: the visual boundary of the very control a person has to find and click. Replacement colours were computed from contrast ratios rather than chosen by eye.

The focus-indicator fix is a nice piece of defensive rendering. The widget lives inside someone else’s page, so relying on the user agent’s focus outline meant a host stylesheet resetting outlines removed it entirely. It now draws both an outline and a box-shadow: an outline reset removes one, forced-colors modes remove the other, and neither alone covers both. Nine criteria are verified in CI against what the browser actually paints, with the focus test deliberately run under a hostile host stylesheet.

Worth keeping distinct: this answers “can a screen-reader user operate the control.” The project’s existing false-positive budget panel answers the harder question for a bot detector — “does the detector decide the screen-reader user is a bot.” That panel — keyboard-only, screen-reader, touch, motor-tremor, elderly, high-latency — still fails the build when any signal exceeds its budget, and remains at 0.00%.

Everything that shipped

ReleaseChange
v1.28.0WCAG 2.2 AA, verified in CI against the rendered widget
v1.28.1A failed proof of work no longer floors the score — the false-positive fix above
v1.28.2Security: the Node library could issue a token without a valid proof of work
v1.29.0Breaking: signing secret required to start; 64 KiB request cap
v1.30.0Token verification compared the wrong two addresses; Siteverify remoteip semantics
v1.31.0Library and server unified on one detection core; invisible-mode signal gating
v1.32.0Redis-backed shared state for the Go server
v1.33.0Redis for Node and Python; two-replica conformance suite
v1.33.1errorCallback no longer handed undefined on a refused verification
v1.33.2Sparse genuine mouse traces no longer scored as no movement
v1.34.0The widget expires its own token; expiredCallback fires at last
v1.34.1Challenges refresh before expiry, so slow forms can still be solved

Upgrading

Where you should land depends on how you deploy:

  • Everyone: v1.34.1. The two widget fixes alone — token expiry and challenge refresh — remove the most common ways a patient human saw “Verification failed.”
  • On v1.23.0 through v1.28.0: v1.28.1 is the priority. Any deployment that restarts, or any visitor who leaves a tab open five minutes, was exposed to the dispositive-floor block.
  • Node library integrators: v1.28.2 is a security fix, and v1.31.0 will move your scores — plan a threshold re-check into the upgrade.
  • Before crossing v1.29.0: set FCAPTCHA_SECRET, or the server will not boot. That is the release working as designed.
  • Loading the widget from the CDN: bump the pinned version and the integrity digest; the v1.34.1 release notes carry the current SRI hashes.

FCaptcha remains open source and self-hosted by default, so none of this involves your traffic leaving your infrastructure. WebDecoy’s own embedded client and hosted demo track the release tag and are on v1.34.1.

For the scoring-arithmetic backstory that set up several of these fixes, see the v1.27 post on preconditions and the corroboration floor.

Frequently Asked Questions

Why was FCaptcha rejecting valid tokens in production deployments? +

Before v1.30.0, the token verification endpoint checked the token against the socket address of whoever called it. But tokens are minted for browser visitors, and the verification call comes from your backend. Those are the same host only on a developer's laptop, so in any real deployment the check compared a visitor's address against a server's and rejected a good token. Verification now follows Siteverify semantics: pass remoteip to bind the token to the visitor's IP, or omit it to skip the check. The caller's own address is never used.

Why does FCaptcha now refuse to start without a signing secret? +

Since v1.29.0, all three servers fail closed when FCAPTCHA_SECRET is absent or still set to the public development key. That key is published in the repository, so any token signed with it can be minted by anyone who has read the source. For production, set your own secret; rotating it invalidates tokens issued under the old default, which is the point. For local-only development, FCAPTCHA_INSECURE_DEV_MODE=1 restores zero-configuration startup with a warning on every boot.

Can FCaptcha run multiple replicas behind a load balancer? +

Yes, since v1.33.0 in all three servers. Setting REDIS_URL moves proof-of-work challenges, token replay protection, Siteverify idempotency, rate limits and suspicion history into Redis, so single-use means single-use across the whole deployment rather than per process. Both failure modes are closed: a server refuses to start if its configured Redis is unreachable, and fails closed if Redis drops out at runtime rather than silently reverting to per-pod state. The Helm chart refuses to render more than one replica without Redis configured.

Why did the FCaptcha widget show verified but the form still fail? +

Servers reject tokens older than 300 seconds, but before v1.34.0 the widget did not know that. The checkbox kept showing verified long past the point the token would be accepted, so a form left open for five minutes failed on submit. The widget now expires its own token slightly before the server-side lifetime, resets to the unverified state, and finally fires expiredCallback, which had been in the public options object since the first release but was invoked nowhere.

Is the FCaptcha npm library as secure as the bundled server? +

It is now; it was not. Through v1.28.1 the exported ScoringEngine could issue a token without a valid proof of work, because it counted the missing proof as weighted evidence instead of enforcing it as a precondition the way the servers do. And through v1.30.0 the library ran a reduced detector set with an aggregation method the server had replaced, so an automated browser could pass the library where the server would refuse it. Since v1.31.0 both entry points run one shared detection core. If you integrate the library directly, re-check your thresholds, because the same traffic now scores higher.

Is the FCaptcha widget WCAG compliant? +

v1.28.0 brought the widget to WCAG 2.2 AA and made CI verify it on every run. An audit found seven contrast failures, the worst being the checkbox border at 1.72:1 against a required 3:1, which is the visual boundary of the control a person has to find and click. The widget also draws its own focus indicator as both an outline and a box-shadow, because a host stylesheet resetting outlines removes one and forced-colors modes remove the other. This complements the existing false-positive budget panel, which asks a different question: not whether a screen reader can operate the control, but whether the bot detector decides the screen-reader user is a bot. That benchmark remains at 0.00%.

Want to see WebDecoy in action?

Get a personalized demo from our team.

Request Demo