Skip to content

v1.19.0 — native JA4-TLS + Go 1.24

Choose a tag to compare

@cport1 cport1 released this 28 Jul 13:31

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 ./server

A 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.