Article title: JA3 Fingerprint Wall in Practice: One md5 Blocked curl, but Four Lines of Parameters Bypassed It
Body:
JA3 Fingerprint Wall in Practice: One md5 Blocked curl, but Four Lines of Parameters Bypassed It
Anyone who does data collection has probably run into this: you rotate the proxy pool, change the UA, use a fresh Cookie pool, and the account still gets banned within seconds. You check the logs and see no anomalous requests—because the other side never even reached the application layer; they cut you off during the TLS handshake.
This is the most common use case for TLS fingerprinting (JA3). This article doesn't discuss theory. I set up a real TLS service on my local machine that blocks by fingerprint, threw three clients—curl, Python, and openssl—at it, and watched how it distinguishes them, how it blocks them, and why this wall is actually very easy to bypass.
All output is from actual local runs; the experiment code is at the end of the article.
1. What JA3 Is Actually Computing
The first packet of a TLS handshake is the ClientHello. It contains a bunch of fields, five of which are in a fixed order for the same client version:
- The list of supported cipher suites (order-sensitive)
JA3's approach is simple: concatenate these five fields into a string in a fixed format, then take the md5.
TLSVersion,Ciphers,Extensions,EllipticCurves,EllipticCurvePointFormats
771,4866-4867-4865-49196-...,65281-11-10-16-22-...,4588-29-23-30-24-25,0
Before concatenating, GREASE values (padding suites of the form 0x?a?a such as 0x0a0a, 0x1a1a) must be stripped out; otherwise the same client would produce a different fingerprint on every handshake.
Note one key point: this string contains no IP, no UA, no application-layer information whatsoever. It is determined entirely by the client's TLS library and its compile-time default configuration.
2. First, Write a Service That Can Compute Fingerprints
To compute JA3, you don't need to complete the handshake—reading the ClientHello is enough. But there's a pitfall here: if you call recv() directly, the bytes get consumed, and there's no data left to continue a normal handshake afterward.
The correct approach is MSG_PEEK:
peek = c.recv(8192, socket.MSG_PEEK)
t = parse(peek)
md5 = hashlib.md5(ja3str(t).encode()).hexdigest()
if md5 in BLOCK:
c.close()
return
# Allow through: the bytes are still in the buffer, so the same socket can be handed to ssl
s = CTX.wrap_socket(c, server_side=True)
MSG_PEEK only reads without consuming, so the allow-through branch can seamlessly continue with the full handshake. This is the first key point when writing this kind of middleware; without it, you'd have to replay the buffer yourself, which is much more troublesome.
The parsing part hard-decodes according to the ClientHello structure:
p = 9 # record(5) + handshake(4)
ver = int.from_bytes(d[p:p+2], "big")
p += 2 + 32 # skip random
p += 1 + d[p] # skip session_id
n = int.from_bytes(d[p:p+2], "big") # cipher suites length
p += 2
cs = [int.from_bytes(d[p+i:p+i+2], "big") for i in range(0, n, 2)]
p += n
p += 1 + d[p] # skip compression
el = int.from_bytes(d[p:p+2], "big") # total extensions length
Then iterate over the extensions, taking type 10 (supported_groups) and type 11 (ec_point_formats).
3. Three Clients, Three Fingerprints
Start the service, and at first only log without blocking. Three clients hit the same port:
$ curl -sk https://127.0.0.1:9600/
hello from JA3 wall
$ python3 -c "...urllib.request.urlopen('https://127.0.0.1:9600/')..."
hello from JA3 wall
{"verdict": "PASS", "md5": "adc2bd4bb2698725", "ciphers": 90, "exts": 12}
{"verdict": "PASS", "md5": "da2e319a5accae0c", "ciphers": 91, "exts": 10}
{"verdict": "PASS", "md5": "8230ec99306445c5", "ciphers": 91, "exts": 11}
Three fingerprints, all different from one another. Where do they differ:
| Client | Cipher suite count | Extension count | Fingerprint prefix |
|---|---|---|---|
| curl | 90 | 12 | adc2bd4bb2698725 |
| python(urllib/OpenSSL) | 91 | 10 | da2e319a5accae0c |
| openssl s_client | 91 | 11 | 8230ec99306445c5 |
All three are OpenSSL-based underneath, differing by only one or two extensions—but in the eyes of the fingerprint wall, these are already three completely different "species." And browsers are a fourth species: Chrome's TLS 1.3 cipher suite order, ALPN list, and extension order are all different from these three.
This explains why "I clearly used a proxy and changed my UA, so why am I still banned": your request has already confessed at the TLS layer.
4. Turn On Blocking: Block Only curl
Put curl's full md5 into the blacklist and restart the service:
$ curl -sk https://127.0.0.1:9601/ ; echo rc=$?
rc=35
35 is curl's SSL connect error. What the client sees is "handshake failed," with no HTTP status code and no response body—from the application layer's perspective, this request never happened.
At the same moment, Python hits the same port:
$ python3 -c "...urlopen('https://127.0.0.1:9601/')..."
hello from JA3 wall
{"verdict": "BLOCK", "md5": "adc2bd4bb2698725", "ciphers": 90, "exts": 12}
{"verdict": "PASS", "md5": "da2e319a5accae0c", "ciphers": 91, "exts": 10}
{"served": "GET / HTTP/1.1", "ja3": "da2e319a5accae0c"}
Precise hit, precise allow-through. That's the entire magic of the fingerprint wall—it doesn't look at what you say, only at what you look like.
5. And Then It Breaks
The following four operations only change command-line parameters, without changing a single line of code.
Change the TLS 1.2 cipher suite order:
$ curl -sk --ciphers 'AES128-GCM-SHA256:AES256-GCM-SHA384' https://127.0.0.1:9601/
hello from JA3 wall
{"verdict": "PASS", "md5": "a89bd0721ccae87c", "ciphers": 5, "exts": 12}
The cipher suites go from 90 to 5, and the fingerprint gets a whole new face.
Keep only one TLS 1.3 suite:
$ curl -sk --tls13-ciphers 'TLS_AES_128_GCM_SHA256' https://127.0.0.1:9601/
hello from JA3 wall
{"verdict": "PASS", "md5": "53bbda3304f732d8", "ciphers": 88, "exts": 12}
Downgrade to TLS 1.2:
$ curl -sk --tlsv1.2 --tls-max 1.2 https://127.0.0.1:9601/
hello from JA3 wall
{"verdict": "PASS", "md5": "ac234bdf6db16ab0", "ciphers": 87, "exts": 8}
The extension count drops from 12 to 8—because several TLS 1.3-related extensions are no longer sent.
openssl changes the curve:
$ echo | openssl s_client -connect 127.0.0.1:9602 -servername a.com -curves X25519
{"verdict": "PASS", "md5": "fcf1a548e666c124", "ciphers": 91, "exts": 11}
Now look again at the entry in the blacklist, adc2bd4bb2698725—it was never hit again.
What This Shows
Article title: JA3 Fingerprint Wall in Practice: One md5 Stopped curl, but Four Lines of Parameters Got Around It
Body: JA3 is a fingerprint, not an identity. It identifies "a certain set of default configurations of a certain TLS library," not "a certain client software." The same curl becomes a different fingerprint with a changed parameter; conversely, if an attacker wants to impersonate Chrome, they only need to tune the parameters to look like Chrome (curl-impersonate in the community does exactly this, hardcoding Chrome's ClientHello into curl).
What's more troublesome is that if your fingerprint wall is in "whitelist" mode (block anything not on the list), then normal users who upgrade their curl version or switch their OpenSSL version will also be falsely blocked. This is the most common incident in production environments.
Six, Why JA3 Is Outdated
Three reasons, ordered from greatest to least impact:
- TLS 1.3 encrypts most of the handshake
In TLS 1.3, everything after ServerHello in the handshake is fully encrypted, and many fields in ClientHello are replaced by EncryptedExtensions. The amount of fingerprint information based on the plaintext handshake is shrinking.
- Chrome's extension order is random
Chrome has randomized the extension order in ClientHello since very early versions (to prevent fingerprint tracking). This means Chrome's JA3 is itself unstable—the same user refreshing continuously will produce a changing md5. To use JA3 to identify Chrome, you first have to deal with this noise.
- The maintenance cost of a fingerprint wall exceeds the benefit
Maintaining a "whitelist of legitimate client fingerprints" requires continuously keeping up with every browser release. Bypassing it only requires changing one parameter. The attack-defense cost is severely asymmetric.
This is why JA4 appeared. JA4's approach is to normalize the unstable parts of JA3:
- Sort cipher suites and extensions before hashing, eliminating order noise
- Encode protocol (TCP/QUIC), version, presence of SNI, first ALPN value, count, and other information in a plaintext prefix
- The format looks like t13d1516h2_55b375c5d22e_eb985b0a4d1d, with the three segments being meta-information, cipher suite hash, and extension hash
The sorting step is crucial: it groups "the same configuration, different order" into the same fingerprint, eliminating the noise from Chrome's randomization. But the cost is that changing the order can no longer create a new fingerprint, while changing the set still can—bypasses 1, 2, and 4 from the earlier experiment still work; only bypass 3 (downgrading to TLS 1.2) is directly exposed in the version bits.
In other words, JA4 is more usable than JA3, but it is still not "identity authentication."
Seven, How Defenders Should Use It
If you are writing a WAF or anti-scraping strategy, don't treat fingerprints as the sole criterion. My suggestion is to layer by "signal strength":
Strong signals (can trigger blocking on their own)
- Fingerprint clearly inconsistent with the UA claim: claims to be Chrome, but is actually an OpenSSL command-line fingerprint. This is the easiest category to catch, with the lowest false-positive rate.
- Known scanner fingerprints (fixed clients such as nikto, sqlmap, masscan, etc.)
- The same fingerprint coming from a large number of different IPs in a very short time—this is a characteristic of proxy pools, and is more reliable than a single IP with high frequency
Medium signals (used for weighting, need to be combined with other dimensions)
- Fingerprint frequency abnormally low (long-tail fingerprint)
- HTTP/2 SETTINGS frame parameters inconsistent with the fingerprint
- TLS extensions containing fields the server doesn't need but the client forcefully sends
Weak signals (log only, don't use to block)
- A standalone JA3/JA4 blacklist hit—this article has already proven it breaks with a single change
- A standalone UA, a standalone IP range
The real engineering approach is combined scoring + tiered handling: low scores only log, medium scores add a CAPTCHA, and only high scores disconnect. The value of fingerprints in this system is "raising the opponent's cost of forgery," not "identifying the opponent."
There is also one often-overlooked point: a fingerprint wall will falsely block your own monitoring and load-testing tools. Before going live, run your internal tools once and whitelist them, otherwise the first one to raise the alarm will be yourself.
Eight, Self-Check List
- [ ] Have you treated the JA3 blacklist as an absolute criterion
- [ ] Have you done consistency validation between fingerprint and UA (this has the best cost-benefit ratio)
- [ ] In whitelist mode, is there a fallback for false blocks caused by client upgrades
- [ ] Is the fingerprint only logged, not used for standalone blocking
- [ ] Have you made joint judgments combining fingerprint with request frequency and IP distribution
- [ ] Have the fingerprints of internal monitoring/load-testing tools been whitelisted
- [ ] Does the target site disconnect at the TLS layer (no HTTP response means a hit)
- [ ] Is the client fingerprint you use the same category as the target site's normal users
- [ ] Do you need to replicate the full handshake stack of a real browser (not just the UA)
- [ ] Is the fingerprint stable (do consecutive requests from the same egress IP produce a drifting md5)
Nine, Experiment Code
products/lab/ja3/
ja3wall.py # MSG_PEEK reads ClientHello, decides allow or disconnect by md5 blacklist/whitelist
ja3waf.py # Observation version that only logs, doesn't block
ja3full.py # Just computes the fingerprint, outputs JSON
capture.py # The earliest read-only ClientHello script
# Generate a self-signed certificate
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout /tmp/ja3lab/key.pem -out /tmp/ja3lab/cert.pem \
-days 2 -subj "/CN=ja3lab"
# Observation mode
python3 ja3wall.py 9600
# Blocking mode: throw curl's fingerprint in
python3 ja3wall.py 9601 adc2bd4bb269872516eeaa7ade78fe75
The entire codebase has no external dependencies and runs on the standard library alone. To verify your own WAF rules, just change the BLOCK set; to see your own client fingerprint, start ja3waf.py on any port, point the client at it, and the server log will print the md5.
One last sentence: a fingerprint is a clue to "who he is," not a credential for "whether he can get in." Any system that treats a clue as a credential will fail the moment the opponent changes one line of parameters.