file 002english

The edge reads you before the payload

date
playtime
00:20:00
location
tlsfingerprintja3ja4cloudflare

How a server decides you are a tool before reading a single byte of your request: the ClientHello field by field, the JA3 that Chrome deliberately broke in 2023, the JA4 that replaced it, and the other layers that count too. With measurements from this machine.

The scene repeats itself. The request works in the browser and fails in curl. You check the header, the token, the body, all identical, and conclude the target “detects tooling”. So you improve the User-Agent, add Chrome’s headers, and keep collecting 403s.

What took me a long time to internalise is that, at that point, the server has probably not read anything you sent. The decision was already made in the handshake, based on how your client introduced itself — before HTTP existed, before a header existed, before a payload existed. There is a whole layer of identity that I spent years filing under “networking detail”, and that in practice decides whether my test starts at all.

This post is about that layer. I am going to open the ClientHello field by field, show why every TLS library produces a stable signature, explain JA3 and why it died for browsers in 2023, decode JA4 end to end, and finish with the layers that are not TLS but count just the same. All of it measured, none of it “they say”.

Anatomy of a ClientHello

Before any fingerprint, the object. The current standard is RFC 9846 (July 2026), which obsoletes RFC 8446. Yes, TLS 1.3 was republished, and almost everything you will read still cites 8446. The ClientHello structure is unchanged:

TEXT
struct {
    ProtocolVersion legacy_version = 0x0303;
    Random random;
    opaque legacy_session_id<0..32>;
    CipherSuite cipher_suites<2..2^16-2>;
    opaque legacy_compression_methods<1..2^8-1>;
    Extension extensions<8..2^16-1>;
} ClientHello;

Six fields. I will walk through each one with a single question in mind: is this signal, or is it noise?

FieldBytesWhat it isDoes it fingerprint?
legacy_version2Pinned to 0x0303 in TLS 1.3Barely: everyone sends the same thing
random32Drawn fresh on every connectionNever. If it shows up in a hash, the hash is broken
legacy_session_id0–32In compatibility mode, 32 random bytesYes: presence and length separate clients
cipher_suitesvariableSuite list, in preference orderHeavily: the set and the order
legacy_compression_methods1Exactly 00 in TLS 1.3A constant today
extensionsvariableSet, order and contentThe richest field of all

Three things here deserve attention, because they are where the mental model usually breaks.

legacy_version is a lie by design. TLS 1.3 does not state its version there; it says 0x0303 (TLS 1.2) and puts the real version in an extension, supported_versions. The reason is historical: old middleboxes rejected hellos carrying a higher version number, so the legacy field was frozen to slip past dumb equipment. The direct consequence for fingerprinting: JA3 reads the legacy field, so it cannot tell 1.2 from 1.3.

The 32-byte session id is “compatibility mode” (Appendix E.4 of RFC 9846). To make a 1.3 handshake look like a 1.2 resumption, the client sends a non-empty session id plus a dummy change_cipher_spec. In my measurements, every TLS 1.3 connection from all 10 stacks sent 32 bytes, and every client restricted to TLS 1.2 sent an empty one, with one exception (Go, which sends 32 bytes in both cases). So an empty session id in a 1.3 hello is a strong “not a browser” signal.

The random does not count. Across two Python requests runs the raw ClientHello bytes differ (0adf02f5c7e64d31… versus 60bebb560e3f784a…) while JA3 and JA4 are identical. That is the definition of a fingerprint that works: the bytes change, the structure does not.

The extensions are the signature

If you memorise one table in this post, make it this one. Extensions are what separates one client from another, and each is identified by a 16-bit number. These are the codepoints that actually show up, checked against the IANA registry:

hexNameWhat it doesWhere it lands
0x0000server_nameSNI: the host you wantd/i in JA4
0x0005status_requestasks for OCSP staplingset
0x000asupported_groupscurves/groups acceptedJA3 (field 4), absent from JA4
0x000bec_point_formatsEC point formatJA3 (field 5)
0x000dsignature_algorithmssignature algorithmsJA4, in order
0x0010ALPNh2, http/1.1…h2/h1 in JA4
0x0012signed_certificate_timestampSCT (CT)set
0x0015paddinghello paddingset
0x0017extended_master_secret1.2 legacyset
0x001bcompress_certificatecertificate compressionset
0x0023session_ticketresumptionset
0x002bsupported_versionsthe real version13/12 in JA4
0x002dpsk_key_exchange_modesPSK modesset
0x0033key_sharepublic key exchange materialcount, content never
0xff01renegotiation_infoanti-renegotiationset
0xfe0dencrypted_client_helloECHset
0x44cdapplication_settings (ALPS)application settingsset

Two rows in that table deserve their own paragraph, because they are the ones I have seen described wrongly most often.

0xfe0d in Chrome is ECH GREASE, not real ECH. Chrome calls SSL_set_enable_ech_grease(ssl, 1) on the opportunistic path, and BoringSSL documents that this “sends a GREASE ECH extension when no supported ECHConfig is available”. Since localhost has no ECHConfig in DNS, what goes out is a fake ECH: 218 bytes on the connection I inspected, with a random payload and a size that varies. The extension exists, but it is hiding nothing: it is extensibility noise.

0x44cd is real ALPS, and it is unregistered. The payload I captured is 00 03 02 68 32: a list length, a protocol length, and the letters h2. It literally says “my application settings are for h2”. Cross-check: on Chrome running with --disable-http2 that extension disappears, and the hello drops from 17 to 16 extensions. BoringSSL calls this family “ALPS (draft-vvv-tls-alps)” and keeps two codepoints (old and new, selected by alps_use_new_codepoint), while the draft leaves the IANA value as TBD. That is why the number you see on the wire is experimental and missing from the registry.

GREASE: noise on purpose

RFC 8701 reserves values to be advertised at random: 0x0A0A through 0xFAFA for extensions, groups, signature algorithms and versions; {0x0A,0x0A} through {0xFA,0xFA} for cipher suites and ALPN. The point is to force the other end to ignore what it does not know, so the ecosystem does not seize up when a new value appears.

For fingerprinting, GREASE is noise injected on purpose, and an accidental anti-fingerprint: the client changes its hello without changing software. In the measurements, only Chrome (and curl_cffi, which copies Chrome) sends GREASE: one cipher, two extensions, one group, one supported_versions entry and one signature algorithm, with values re-randomised on every connection. The other nine stacks send none.

TLS 1.2 and 1.3 don’t look alike on the wire

Worth putting side by side, because it is the easiest difference to lose:

TLS 1.2TLS 1.3
versionlegacy_version and nothing elsefixed legacy_version + supported_versions
key exchangegroups and point formatskey_share with material in the hello
compressiona real listexactly 00
session idreal resumption32 random bytes (compat)
resumptionsession id / ticketPSK + psk_key_exchange_modes

The effect on fingerprints is direct and a little cruel: because JA3 reads the legacy version, its version field is the same (771) for 1.2 and for 1.3. What separates the two hellos is the rest of the list, not the version. JA4 reads supported_versions and marks t12 against t13 in the first part. And since 1.3 requires key_share and psk_key_exchange_modes, the extension count moves with it.

Every stack writes its own hello

Here is the structural insight that makes the rest of this make sense: the ClientHello is not negotiated, it is written. It is the output of a table compiled into the binary.

In BoringSSL, ssl/extensions.cc holds a kExtensions[] table, and the builder walks that table calling each entry’s add_clienthello. In practice the extension order is a compile-time constant, except when it is shuffled. We will get there. The cipher list is a table too: kCiphers[], in ssl_cipher.cc, with the three TLS 1.3 suites in a fixed order.

Except “compiled table” does not mean “identical across stacks”. Measuring the same request through different tools:

Client measuredCiphers / extensions (no GREASE)JA4
macOS curl 8.7.1 (LibreSSL 3.3.6)49 / 7t13d4907h2_0d8feac7bc37_7395dae3b2f3
Homebrew curl 8.21.0 (OpenSSL 3.6.4)30 / 12t13d3012h2_1d37bd780c83_882d495ac381
Python 3.15 (requests and httpx, OpenSSL)17 / 12t13d1712h1_ab0a1bf427ad_0d46a1bf4a7c
Go 1.25 net/http13 / 12t13d1312h2_f57a46bbacb6_ab7e3b40a677
Java 21.0.8 java.net.http37 / 13t13d3713h2_db35923f8641_7c76daad20ec
Node 26.8.2 (undici)52 / 12t13d5212h1_b262b3658495_8e6e362c5eac
Chrome 15615 / 17t13d1517h2_8daaf6152771_cb7bf5808d99

Look at the first two rows. They are the two curl on the same machine: one is the system build, the other is Homebrew’s. Same command, same name, different fingerprint, because the macOS one uses LibreSSL and the Homebrew one uses OpenSSL. If you copied a Linux tutorial and ran it on a Mac, you are advertising a different stack.

Language is not the signal either. Python and Node both sit on OpenSSL; Python sends 17 ciphers and Node sends 52. What counts is the library and its version, not the language.

JA3: one line, one MD5, and a problem

JA3 (Salesforce, 2017) is about as simple as an idea gets: take five fields from the hello, join them into text, hash it.

TEXT
SSLVersion,Cipher,SSLExtension,EllipticCurve,EllipticCurvePointFormat

Decimal values, , between fields, - inside a field, MD5 at the end. The example from the official README, which I recomputed:

TEXT
769,47-53-5-10-49161-49162-49171-49172-50-56-19-4,0-10-11,23-24-25,0
  → ada70206e40642a3e4461f35503241d5

It worked in 2017 because it was cheap to compute and discriminating enough. And it still works today for what it is: a way of saying “these bytes appeared in this order”.

Except “in this order” is the fragile part, and the list of weaknesses is short and honest: order matters; there is no extension content (only the numbers); 1.2 and 1.3 blur together; MD5 is an identifier, not an authenticator; and the implementation divergence Fastly measured. None of those is fatal on its own. Together, they left JA3 ready to be killed.

And in 2023 someone killed it on purpose.

2023: Chrome broke JA3 on purpose

In the Chromium source, net/socket/ssl_client_socket_impl.cc, there is an unconditional line on the TLS connection path:

CPP
SSL_set_permute_extensions(ssl_.get(), 1);

On the other side, in BoringSSL, ssl_setup_extension_permutation() runs a Fisher-Yates shuffle with RAND_bytes over the kExtensions indices — and the emit loop walks the permutation instead of the natural order. In plain terms: Chrome shuffles the extension order of the ClientHello on every connection.

The motive is anti-ossification, and it is a good motive: if servers get used to a fixed order, any future change to Chrome breaks the internet. Shuffling forces the ecosystem to treat order as irrelevant. The side effect is that JA3, which depends on exactly that order, turned into noise.

Fastly measured the damage at the time: from 20 January 2023 the most common Chrome JA3 on their network fell away, and since the number of permutations is “nearly 15 factorial (about 10¹²)”, in practice every connection produces a unique JA3. Cloudflare put it bluntly: it made “older TLS fingerprints almost useless for identifying the latest version of Chrome”.

Now the number I measured, because this is where theory becomes obvious. Three fresh connections from the same Chrome 156 build:

TEXT
connection 1  →  JA3 0e250605ce1f2c793bb1a2269dd30e01
connection 2  →  JA3 47e376a38884e22f8a6bb4661daa71a9
connection 3  →  JA3 5c34099f1b500a60c0deacb3f8d31187
         all  →  JA4 t13d1517h2_8daaf6152771_cb7bf5808d99

Three different JA3. One JA4. If you built a WAF rule on browser JA3, it has been dead for three years.

JA4: how to read the whole fingerprint

JA4 (FoxIO, 2023) solves this with two decisions: sort what is arbitrary, and put readable information in front of the hash. The format is a_b_c:

TEXT
t13d1516h2_8daaf6152771_e5627efa2ab1

I am going to decode that specification example end to end, because it is the best study material there is, and because I recomputed every hash.

PartValueMeaning
ttTLS over TCP (q = QUIC, d = DTLS)
1313TLS 1.3, read from supported_versions
ddSNI present (i = no SNI, went to an IP)
151515 cipher suites, not counting GREASE
161616 extensions, not counting GREASE
h2h2first and last character of the first ALPN
8daaf6152771hashsha256 of the sorted ciphers, truncated to 12
e5627efa2ab1hashsha256 of the sorted extensions (minus SNI and ALPN) + sigalgs in order

The 15 ciphers, named from the IANA registry: 002f and 0035 (RSA CBC), 009c and 009d (RSA GCM), 1301/1302/1303 (the three TLS 1.3 suites), c013/c014 (ECDHE_RSA CBC), c02b/c02c (ECDHE_ECDSA GCM), c02f/c030 (ECDHE_RSA GCM) and cca8/cca9 (ECDHE ChaCha20). The classic modern-client mix: the 130x for 1.3 plus a 1.2 basket for talking to old servers.

The 16 extensions: 001b compress_certificate, 0000 SNI, 0033 key_share, 0010 ALPN, 4469 ALPS (the old codepoint), 0017 extended_master_secret, 002d psk_key_exchange_modes, 000d sigalgs, 0005 status_request, 0023 session_ticket, 0012 SCT, 002b supported_versions, ff01 renegotiation_info, 000b ec_point_formats, 000a supported_groups, 0015 padding.

Recomputing with sha256sum:

TEXT
sha256("002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9")[:12]
  = 8daaf6152771

sha256("0005,000a,000b,000d,0012,0015,0017,001b,0023,002b,002d,0033,4469,ff01_0403,0804,0401,0503,0805,0501,0806,0601")[:12]
  = e5627efa2ab1

Two details that are worth more than memorising the rules. First: SNI and ALPN are excluded from c on purpose, because they are already in a. So the same client produces the same c whether it goes to a domain or an IP. Second: sigalgs go in in the order they appear, unsorted, glued on after a _. Sort them and the hash changes; I watched that mistake appear in a lab, and FoxIO publishes the vector that gives it away (0403,0804,0401,…, unsorted).

Now the reveal, comparing the spec example with my measured Chrome 156:

TEXT
spec       t13d1516h2_8daaf6152771_e5627efa2ab1
Chrome 156 t13d1517h2_8daaf6152771_cb7bf5808d99

The b is identical. Chrome’s cipher list did not change between those builds. What changed were the extensions: one more (17 against 16), and c moved with it. That proves the algorithm is doing its job: the stable part stayed stable, the part that changed changed.

Sorting is a design decision

What I find most elegant about JA4 is not the format, it is the choice of where to sort. I built the minimal case: the same set of extensions, in two orders.

TEXT
order A: 10-11-13-16-23-35-43-51  →  JA3 692aefc05b6b8a9afca0d6486e3b15e4
order B: 51-43-35-23-16-13-11-10  →  JA3 79e887fad94c5bfc974a532532781ec3
                                     JA4_c 8ccb6ff8ed2a (identical in both)
Permutation diagram: the same list of eight extensions in two orders produces two different JA3 hashes and a single JA4_c
same list, two orders, two JA3 and one JA4

Two JA3, one JA4. And the rule behind it is not “order doesn’t matter”: it is that you sort what is semantically irrelevant and actively randomised. TLS extensions are both. HTTP header order, on the other hand, is randomised by nobody and carries information — and JA4H, the HTTP client fingerprint from the same family, does not sort headers. JA4S, which looks at the server’s answer, preserves the original order on purpose.

Three decisions, one ruler. That is what separates a format that ages well from one that has to be replaced within five years.

What JA4 chooses not to see

No fingerprint sees everything, and it is worth being explicit about what is left out, because that is where bad diagnostic decisions come from.

Not in JA4WhyPractical consequence
random, session id, key_share bytesthey are randomthe hash is stable; the bytes never are
supported_groupsdeliberately outside ctwo clients differing only in a post-quantum group give the same JA4
the SNI stringonly “present or not”the hostname is not in the hash
extension ordersortedinvisible to JA4, visible to JA3
cipher ordersortedyou gain stability and lose the preference
ALPN beyond the first/last charonly 2 charactershttp/1.1 and http/1.1,h2 look identical

That last one is the most treacherous, and it is in my records: httpx in HTTP/2 mode offers http/1.1, h2, the reverse order, and produces exactly the same JA4 (t13d1712h1_…) as requests, which only offers http/1.1. The a field takes the first and last character of the first value, so http/1.1 becomes h1 and the trailing h2 disappears. A sensor that only looks at JA3/JA4 cannot tell that client asked for HTTP/2.

And post-quantum deserves a note: Chrome, Go, Node and OpenSSL 4 offer X25519MLKEM768 (0x11ec, defined in RFC 10024); Java 21 and macOS curl do not. Because JA4 hashes neither groups nor key_share content (Chrome sends a 1216-byte share, different on every connection), the same client keeps the same JA4 before and after PQ. The instability from that change lives entirely in JA3, which hashes supported_groups.

It isn’t only TLS

If you got this far thinking the problem is TLS, the bad news is that there are three more layers. And a fourth thing that is not a layer at all, it is history.

Diagram of the layers: TCP/IP, TLS, HTTP/2, HTTP and behaviour, with what the edge measures on each and whether it can be forged
each layer has an owner · not all accept a disguise

HTTP/2. The grammar came from an Akamai white paper in 2017 and it is still the one everyone implements: the fingerprint is built from the frames a client sends before any useful request. The notation is S[;]|WU|P[,]#|PS[,]: SETTINGS parameters with their values, in the order they appear; the WINDOW_UPDATE increment; the priority tuples; and the pseudo-header order, encoded as m, p, a, s. The paper measured more than 10 million connections and, in 2017, published each browser’s order: Chrome m,a,s,p, Firefox m,p,a,s, Safari m,s,p,a.

A premise correction, because I have repeated the error myself: the Akamai format does not include HEADER frame flags. The three flows are SETTINGS, WINDOW_UPDATE and PRIORITY, plus pseudo-header order. If you see someone attributing HEADER flags to Akamai, the source does not exist.

But 2017 is 2017. I measured the clients on this machine, with ALPN negotiating h2 on every connection so nothing degrades silently. Two of them, in the format above:

TEXT
Chrome 156   1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p
Java 21      1:16384;2:0;3:0;4:16777216;5:16384;6:393216|33488897|0|a,m,p,s

Chrome’s PS is still m,a,s,p: the 2017 row survived. Go is still a,m,p,s, matching the paper. And curl moved: it was m,p,s,a on 7.54 and became m,s,a,p on 8.x. That is why you pin the version next to every fingerprint in a table; without it, the table goes stale without warning.

The P field is dead. Across twelve connections, zero PRIORITY frames: P=0 in all of them. The paper’s P was built around Firefox 53’s reserved-stream priorities; today’s Chrome sends the deprecated priority block inside HEADERS plus an RFC 9218 priority header, and the grammar encodes neither. So P=0 means “no PRIORITY frame”, not “no priorities”. It is an honest measure of what a 2017 format loses in 2026.

And the lesson from the TLS section repeats one layer up: the two curl on this machine send the same WINDOW_UPDATE (1048510465) and the same SETTINGS order (3;4;2), but Homebrew sends INITIAL_WINDOW_SIZE=65536 while the macOS one sends 10485760. Same tool, different build, different string.

This matters because a proxy that terminates the connection (and Burp terminates it) builds its own SETTINGS, not the browser’s. And curl_cffi shows the limit of all of it: it ties Chrome on TLS (identical JA4) and on h2 (identical string), and still gets caught one layer up: the measured Chrome 156 sends HeadlessChrome/156 with 17 headers, curl_cffi sends Chrome/152 on Windows with 18, including cache-control. An impersonator has to be right at every layer, and the layers are ordered.

HTTP headers. Order, casing and defaults. JA4H covers this by hashing the names in the order they appear. The hygiene almost everyone forgets is this: if you forge the User-Agent to “Chrome 131” but leave sec-ch-ua saying another version, you did not become more browser-like. You became inconsistent, which is worse than the original.

TCP/IP. TTL, window, MSS and option order come from the kernel, not from your client. p0f has been doing this off a single SYN forever, and the modern equivalent is JA4T, in the format window_options_mss_wscale (FoxIO’s example: 64240_2-1-3-1-1-4_1460_8). It is the layer you cannot fix with a client flag — and why spoofing TLS from a VPS still tells a different story.

A fingerprint is a key, not a verdict

The most common conceptual mistake is assuming the edge decides by looking at a hash. It does not, and Cloudflare documents the opposite: alongside JA4 it computes what it calls JA4 Signals: aggregate statistics over the last hour, globally. The names are explicit: browser_ratio_1h, h2h3_ratio_1h, reqs_quantile_1h, uas_rank_1h, paths_rank_1h, cache_ratio_1h, ips_rank_1h, among others.

JA4 is the key. What carries weight is how that key is distributed. A vanishingly rare JA4 claiming to be Chrome, with a low browser_ratio_1h and high volume, is a lost cause, and no cipher adjustment fixes it. Worse: those signals are analytics, not rules (“They cannot be used as filter fields in WAF custom rule expressions”), so you cannot even simulate the decision by looking only at the hash.

And there is one confusion that costs hours: a block and a challenge are not the same thing. A challenge comes back as HTML, with the header cf-mitigated: challenge, and exists to run JavaScript in your browser. A cf_clearance obtained there is bound to the device that solved it, and solving from one IP while using it from another invalidates the solve. If that is your case, the problem is not TLS at all, and no impersonator will fix it.

Who computes this today

This is not a research niche. It is something you can write a rule against in production:

WhereWhatSince
CloudflareJA4 in Bot Management (Enterprise), field cf.bot_management.ja4JA4+Signals announced Aug 2024
AWS WAFJA3Sep 2023
AWS WAFJA4, including as a rate-based rule keyMar 2025
Google Cloud Armororigin.tls_ja4_fingerprint and origin.tls_ja3_fingerprintdocumented today
AkamaiJA4 (beta, via injected header)2026
FastlyVCL variable tls.client.ja4 and NGWAFFeb 2025

Across the 38 connections, the 10 stacks produced 24 distinct JA4, and three different clients collapsed onto the same one (requests, httpx in h1 and in h2). That sums up the state of the art: JA4 is good at saying “what kind of software is this”, and insufficient to say “which tool exactly”. For that you need the next layer.

What I take from this

Four things, and none of them is “use tool X”.

The block happens before the payload. If your encoding, your header and your token are right and you still get a 403, the answer was probably decided in the handshake. Staring at the body at that point is staring at the wrong place.

The stack is the signal, not the language. Two curl on the same machine, different fingerprints. A library upgrade changes your identity on the network, and nobody tells you.

Browser JA3 died in 2023, on purpose. It is not a bug and not a maintenance gap: it is an engineering decision by Chrome that Fastly measured and Cloudflare commented on. Anyone still building detection on browser JA3 is tracking a target that moves on every connection.

And the fingerprint is never the whole story. It is one key in a decision that also weighs prevalence, ASN, rate, cookie and behaviour. Confusing “my TLS is wrong” with “my IP is burned” is the fastest way to spend the whole afternoon tuning ciphers without changing anything.

In the next post I take the opposite route: I pick up Burp, show across three layers why it announces itself as a Java tool, and what can be done about it — including what cannot, and why.