v1.19.0 — native JA4-TLS + Go 1.24
Infrastructure release. Adds a TLS fingerprint FCaptcha computes itself, and corrects a latent trap in all three servers.
Native JA4-TLS
JA4 was previously only available by trusting a reverse-proxy header. It can now be computed here, from the ClientHello, when the Go server terminates TLS itself:
FCAPTCHA_TLS_CERT=/path/cert.pem FCAPTCHA_TLS_KEY=/path/key.pem ./serverA locally-derived fingerprint cannot be asserted by a client or a misconfigured proxy, so when both sources exist the native one wins. Behind Railway, Cloudflare or nginx nothing changes — the ClientHello is consumed upstream, and TRUSTED_JA4_HEADERS remains the only source. The startup log now says which is in effect, or that neither is.
Requires Go 1.24. crypto/tls only began exposing the ClientHello extension list in that release (ClientHelloInfo.Extensions, golang/go#32936); without it the extension hash cannot be computed and JA4 is not derivable from the stdlib. Five version pins moved together — server-go/go.mod, server-go/Dockerfile, docker/Dockerfile, and the vendored pair in fcaptcha-cloud.
JA4 (TLS) only. It is BSD-3-Clause with an explicit patent disclaimer. JA4H, JA4T, JA4L, JA4S, JA4X and JA4SSH are FoxIO License 1.1 — non-commercial, patent-pending, GPL-incompatible — and cannot ship in an MIT project that people deploy commercially. A test fails if one appears.
A mislabelled fingerprint that would have flagged every Chrome user
All three servers carried the same commented-out example:
"t13d1516h2_8daaf6152771_02713d6af862": "Go default stdlib TLS"
That is the canonical example from the JA4 specification, and it is Chrome. Measured with this release's own implementation against a real TLS listener:
| client | JA4 |
|---|---|
| Chromium 141 | t13i1515h2_8daaf6152771_806a8c22fdea |
| Go stdlib | t13i131000_f57a46bbacb6_e5728521abd4 |
| curl | t13i4906h2_0d8feac7bc37_7395dae3b2f3 |
| node https | t13i521000_b262b3658495_8e6e362c5eac |
| python urllib | t13i171000_ab0a1bf427ad_8e6e362c5eac |
The middle section hashes the cipher list — the TLS stack itself. 8daaf6152771 is Chrome's; Go's is f57a46bbacb6. It was inert while commented out, but one uncomment away from scoring every Chrome visitor as automation at 0.8/0.9, in whichever server it was uncommented in. All three maps now ship explicitly empty, with the measured table alongside.
What this detects today: nothing, deliberately
The map CheckJA4Fingerprint consults is empty on purpose. A static hash list is defeated by rotating a fingerprint, and the readings above already drift from the specification's own example through version churn alone. Populating it with values nobody has observed buys false-positive risk and no detection.
The aggregate JA4 Signals model is not built either: it needs population-scale ratios a single self-hosted instance does not have. What ships is the correct computation and the plumbing, so a fingerprint is available to correlate later and to populate a list from abuse you actually see.
If you populate that map, populate it from your own deployment and record where each fingerprint came from.
Compatibility
No breaking changes. Plain HTTP remains the default; the TLS variables are opt-in. Existing TRUSTED_JA4_HEADERS deployments are unaffected.
Tests: 12 new JA4 tests (GREASE exclusion, the count-includes-but-hash-excludes asymmetry for SNI/ALPN, signature algorithms deliberately unsorted, order insensitivity, native-over-header precedence, a real TLS handshake end to end, and the licensing boundary). Node 93/93 E2E, Go 89/93, Python 80/93 — unchanged.