JA4 fingerprints the TLS ClientHello, the first message of a TLS handshake: which TLS version, ciphers, extensions and ALPN protocols (such as HTTP/2) a client offers. HTTP libraries, headless browsers and real browsers produce different fingerprints, and changing it takes more than changing a header. A scraper sending Chrome's user agent from Python's TLS stack keeps Python's JA4.
The application never sees the ClientHello: the TLS terminator in front of it
does. Limen therefore expects the terminator to compute the fingerprint and
pass it in a request header, x-ja4 by default.
What the terminator must do
- Compute the JA4 fingerprint of each connection. Stock nginx does not; you
need nginx built with a JA4 module, such as FoxIO's ja4-nginx-module,
which patches nginx to capture the ClientHello and exposes the fingerprint
as
$http_ssl_ja4. Other terminators have their own plugins; HAProxy and Envoy setups work the same way. - Send it upstream in a header, overwriting anything the client sent.
- Send the client address in
X-Forwarded-For(appending) and the scheme inX-Forwarded-Proto.
With nginx and FoxIO's module (other modules name the variable differently):
location / {
proxy_pass http://phoenix;
# Overwrite, never append: the client must not be able to choose it.
proxy_set_header X-JA4 $http_ssl_ja4;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
# LiveView and channels.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}What Limen must know
config :my_app, Limen,
trusted_proxies: ["10.0.0.5"], # the nginx hosts
client_ip_header: "x-forwarded-for",
ja4_header: "x-ja4"Limen only reads the JA4 header when the connection comes from a trusted
proxy. From anyone else it is ignored and the decision's evidence records
ja4: :untrusted_peer, so a client connecting directly cannot claim a
fingerprint. Malformed values are ignored too (ja4: :malformed).
Only well-formed JA4 fingerprints are accepted, such as
t13d1516h2_8daaf6152771_02713d6af862. Raw or original-order variants are
not.
Check it works
Request a page through nginx and look at the decision:
Limen.decision(conn).identity.ja4or in the explanation of any sampled decision, whose identity lines include
ja4: t13d.... The LiveDashboard page lists the fingerprints with the most
clients (prefixes) in the current minute.
Using fingerprints
JA4 is part of the client identity: challenge tokens, pass cookies and socket
tokens are bound to it, so a pass solved in a browser cannot be replayed from
a script. In policies, signal(:ja4) works with lists and limits:
deny :known_bad_ja4, when: signal(:ja4) in list(:bad_ja4), ban: 3_600
limit :per_fingerprint, key: :ja4, rate: 200, per: :second, burst: 400
score :fingerprint_burst, 30, when: rate(:ja4, per: :second) > 100Popular browsers share fingerprints across millions of users, so treat a
fingerprint on its own as a weak signal and a known-bad fingerprint as a
strong one. Keep :bad_ja4 current with Limen.Lists.put/3 from a periodic
job; Limen does not ship a list.
Licensing
JA4 (the TLS client fingerprint) is BSD-3-Clause licensed. Other methods of the JA4+ family, and FoxIO's nginx module, are published under the more restrictive FoxIO License; check its current terms before using any of them. Limen only consumes JA4.