The TLS 1.3 handshake, frame by frame, for people who fingerprint it
Open a packet capture of an HTTPS connection and the first two records tell you almost everything a fingerprinting system will ever learn about the client from the TLS layer. The ClientHello goes out in cleartext. The ServerHello comes back in cleartext. After that, a hard cryptographic curtain drops. The certificate, the server’s extensions, the signatures, the Finished MACs, all of it is already encrypted under keys neither endpoint announced on the wire. That curtain is the single most important fact about TLS 1.3 if your job is to read handshakes, because it draws a precise line between what you get to inspect and what you have to infer.
The question this post answers is narrow and mechanical. Going message by message through the RFC 8446 handshake, which fields are observable, which are encrypted, and at exactly which message does the encryption boundary fall? The answer is more textured than “everything after ServerHello is encrypted,” because the encryption boundary, the fingerprintable surface, and the privacy boundary are three different lines that happen to sit near each other.
Here is the route. First the shape of the full 1-RTT handshake and the notation RFC 8446 uses to mark its own encryption boundaries. Then the ClientHello field by field, since that one message is where JA3, JA4, and every server-side TLS fingerprint comes from. Then the HelloRetryRequest detour and why it exists. Then the ServerHello and the precise moment keys come alive. Then the encrypted block, EncryptedExtensions through Finished, and what a passive observer can and cannot still pull out of it. We close on the key schedule that makes the boundary real, and on Encrypted Client Hello, which is busy moving the last cleartext field behind the curtain.
The full handshake at a glance
The TLS 1.3 full handshake is one round trip in the common case. The client sends a ClientHello carrying a guessed key share. The server replies with a ServerHello carrying its own key share, and because both endpoints can now derive the same shared secret, the server immediately follows with a block of encrypted handshake messages and can even start sending application data. The client verifies, sends its own Finished, and the connection is open. RFC 8446 calls this the 1-RTT handshake, and it works because the client speculates: it ships a Diffie-Hellman key share in the very first flight instead of waiting to be told which group the server wants.
RFC 8446 marks its own encryption boundaries in the message-flow diagram, and the notation is worth memorising because it is exact. A message wrapped in {} is protected with the sender’s handshake_traffic_secret. A message wrapped in [] is protected with the sender’s application_traffic_secret. A message with no brackets is in cleartext. By that convention only two messages in the entire handshake are unbracketed: ClientHello and ServerHello. Everything else the server sends, starting with EncryptedExtensions, is inside {}.
That two-message cleartext window is the whole game for a TLS fingerprinter. JA3, JA4, and the proprietary signatures that DataDome, Akamai, and Cloudflare compute all live inside the ClientHello, with a smaller fingerprint occasionally drawn from the ServerHello. Nothing that arrives after the ServerHello contributes to a client TLS fingerprint, because by then the bytes are ciphertext. So the rest of this post spends most of its time on those first two messages, then explains what the encrypted block still leaks despite being encrypted.
One more convention before we walk it. RFC 8446 marks optional messages with * and marks the conditional CertificateRequest, Certificate, and CertificateVerify that way. In a normal web browse the server authenticates and the client does not, so the client’s Certificate and CertificateVerify are absent. When a server does demand a client certificate, that mTLS variant becomes the strongest TLS-layer client fingerprint there is, which we touch on at the end and which has its own post.
ClientHello: the one message that fingerprints you
The ClientHello is the richest fingerprinting artefact on the modern internet that a client emits before it has authenticated anything. It is a list of preferences, sent in the clear, and the precise contents and ordering of that list vary enough between TLS stacks that you can often name the stack from the bytes alone. A Chrome ClientHello does not look like a Firefox one, and neither looks like Go’s crypto/tls or Python’s requests. The field-by-field anatomy of the ClientHello deserves its own treatment; here we care about which fields exist and what they give away.
The message starts with a legacy_version field, and this is the first place TLS 1.3 lies for compatibility. The field is hardcoded to 0x0303, the value that meant TLS 1.2, regardless of what the client actually supports. The real version negotiation moved into an extension. A TLS 1.3 ClientHello is identified by having legacy_version of 0x0303 together with a supported_versions extension that lists 0x0304 as its highest entry. Middleboxes that hard-coded assumptions about the version field were the reason for this contortion, and it tells you something about deploying a new TLS version into a network full of devices that parse but do not understand it.
After the version comes the 32-byte random, then a legacy_session_id that in TLS 1.3 carries no resumption meaning and exists only so the handshake can be made to look like a TLS 1.2 session resumption to onlookers. Then the parts that matter for fingerprinting: the list of cipher_suites, the legacy_compression_methods (a single null byte, since compression is gone), and the extensions block.
The extensions are where TLS 1.3 put the real protocol. The key_share extension carries one or more ephemeral Diffie-Hellman public keys, one per group the client is willing to bet the server will accept. The supported_groups extension lists every group the client supports, in preference order, and RFC 8446 requires that each key_share entry corresponds to a group in supported_groups and appears in the same order. The signature_algorithms extension lists which signature schemes the client will accept on the server’s certificate. The server_name extension carries the SNI, the plaintext hostname. The application_layer_protocol_negotiation extension carries the ALPN list, usually h2 and http/1.1. And psk_key_exchange_modes plus pre_shared_key, when present, drive resumption.
Every one of those is observable, and the combination is the fingerprint. JA3, published by Salesforce engineers in 2017, took five fields from the ClientHello, the version, the cipher list, the extension list, the elliptic curves, and the curve formats, joined them with delimiters, and hashed the string with MD5 to a 32-character signature. It was cheap, it was deterministic, and for a few years it reliably separated browsers from scripts. The full mechanics of JA3 and its successor JA4 are their own subject, but the relevant fact here is that JA3 took the extension order as a feature.
That assumption did not survive contact with Chrome. Starting around 20 January 2023, with the change landing in Chromium for Chrome 110 and taking effect as early as versions 108 and 109, Chrome began permuting the order of its ClientHello extensions on every connection. Fastly measured the result: with roughly 15 extensions in play the order space is near 15 factorial, about 10^12 permutations, so practically every Chrome connection produced a unique JA3. The one extension that does not move is pre_shared_key, because RFC 8446 requires it to be the last extension in the ClientHello. Chrome’s stated reason was anti-ossification, the same instinct behind GREASE, but the side effect was that an entire generation of JA3 blocklists went stale overnight.
JA4, from FoxIO, was the response. Instead of hashing the extension list in wire order, JA4 sorts the cipher suites and the extensions before hashing them, so a permuted ClientHello collapses back to a stable fingerprint. The format is human-readable in its first segment and worth seeing decomposed, because it makes concrete which ClientHello fields a modern fingerprinter actually keeps.
*JA4's first segment is plain text you can read at a glance; the two hashes fold the sorted cipher and extension lists. Sorting is what makes it survive Chrome's per-connection extension shuffle.*Two details in that diagram are the load-bearing ones. GREASE values get dropped before any counting or hashing happens, which they have to be, because GREASE is random by design. And the SNI and ALPN extensions are pulled out of the extension hash, because both vary with the destination and the negotiated protocol rather than with the client stack, so including them would smear the fingerprint across every site the client visits.
GREASE deserves a sentence of its own here. RFC 8701 reserves a scattering of cipher, extension, group, and version code points that clients sprinkle into the ClientHello at random, purely so that servers stay in the habit of ignoring values they do not recognise. Chrome shipped it from version 55 in late 2016. A client that emits GREASE is almost certainly a real browser or something deliberately imitating one, and a client that emits none is a small tell on its own. The role of GREASE in the ClientHello is a fingerprinting subtopic in itself.
HelloRetryRequest: when the client’s guess was wrong
The 1-RTT handshake depends on the client guessing right about which Diffie-Hellman group the server wants. The client puts a key_share for, say, X25519 in the first flight and hopes the server is happy with X25519. Usually it is. But the server might require a group the client supports yet did not send a share for. The client listed it in supported_groups, just did not spend the bytes to send a full key share for it. When that happens the server cannot complete the handshake from the first flight, so it sends a HelloRetryRequest.
The mechanism is a small piece of protocol design that repays attention. The server has to find an acceptable group that the client offered in supported_groups but did not provide a key_share for. RFC 8446 is explicit about both halves: on receiving the HelloRetryRequest the client must verify that the selected group is one it listed in supported_groups, and that it is not one it already sent a share for. The second check stops a downgrade where an attacker bounces a client off a group it already tried. The client then resends its ClientHello with the key_share list replaced by a single entry for the named group, and the handshake proceeds, now at two round trips instead of one.
The detail that catches people decoding captures is the way HelloRetryRequest is encoded. It is not a distinct message type on the wire. It uses the exact ServerHello structure, with the random field set to a fixed, specified constant: the SHA-256 of the ASCII string “HelloRetryRequest”. RFC 8446 prints the bytes.
For a fingerprinter, a HelloRetryRequest in the trace is itself a small signal. It means the client offered a supported_groups set whose first-choice share did not match the server’s preference, which says something about the client’s group ordering and which groups it bothered to send shares for. Browsers tuned against the big CDNs rarely trigger it; an unusual client that sends a share for only one exotic group and then has to retry stands out. The retry does not break the cleartext window, though. Both the HelloRetryRequest and the second ClientHello are still unencrypted, because no shared secret exists yet.
ServerHello: the moment the keys go live
The ServerHello is the second and last cleartext message. It mirrors the ClientHello structure: legacy_version pinned to 0x0303, a 32-byte random, a legacy_session_id_echo that simply repeats the client’s session id, a single chosen cipher_suite, a null compression byte, and an extensions block. The constraint on that extensions block is the important part. RFC 8446 says the ServerHello must only include extensions required to establish the cryptographic context and negotiate the version. Every TLS 1.3 ServerHello carries supported_versions, and it carries key_share, or pre_shared_key, or both when a PSK is being used with fresh Diffie-Hellman.
That constraint is deliberate, and it is why TLS 1.3 leaks so much less than 1.2 from the server side. In TLS 1.2 the server’s extensions, certificate, and a great deal else travelled in cleartext, which gave passive observers and server-fingerprinting tools a lot to work with. TLS 1.3 moved everything that is not strictly needed to derive keys out of the ServerHello and into the encrypted EncryptedExtensions message. So the ServerHello tells an observer only: the negotiated version, the negotiated cipher, and the server’s key share. That is enough to build a modest server-side fingerprint, the JA4S in FoxIO’s suite, but it is a thin slice compared with what the client gave up.
The phrase “the moment the keys go live” is literal. When the server emits its key_share, it has just received the client’s key_share, so both endpoints now hold the same (EC)DHE shared secret. From that secret each side independently runs the key schedule and arrives at the handshake traffic secrets. The very next byte the server sends, the EncryptedExtensions message, is already encrypted under server_handshake_traffic_secret. There is no separate ChangeCipherSpec negotiation that matters, no key-exchange round trip, no announcement. The transition from cleartext to ciphertext happens in the gap between the ServerHello and the message immediately after it, and nothing on the wire signals it except that the next record no longer parses as a handshake message.
The encrypted block: EncryptedExtensions through Finished
Everything from here is inside {}. Walk it in order.
EncryptedExtensions comes first. It carries the server’s responses to ClientHello extensions that were not needed to set up the cryptographic context. ALPN selection lives here. So does the server’s supported_groups response, the server_name acknowledgement, and any other negotiated parameter that does not feed key derivation or attach to a specific certificate. In TLS 1.2 a chunk of this was cleartext in the ServerHello. Moving it behind the curtain is the single biggest reduction in passive-observer visibility that 1.3 delivered on the server side. An observer can no longer see which ALPN the server picked, which is why ALPN is fingerprinted from the ClientHello offer rather than the server’s selection.
Then the certificate chain, in the Certificate message, holds the server’s certificate and any per-certificate extensions. This is the field that most surprises people coming from TLS 1.2, where the certificate was plaintext and trivially read by anyone on the path. In 1.3 it is encrypted. A passive monitor that used to identify services by reading the certificate common name off the wire cannot do that against a 1.3 connection. That single change broke a whole category of network-management and censorship tooling, and is part of why Certificate Transparency logs and SNI inspection became more important to anyone trying to attribute a connection to a service: the certificate itself stopped being visible mid-flight.
CertificateVerify follows: a signature, made with the private key matching the public key in the certificate, over a transcript hash of the entire handshake so far. This is what proves the server actually holds the key for the certificate it just presented, and crucially the signature now covers the cipher negotiation too. In TLS 1.2 the negotiation parameters were not signed, which left room for an attacker to strip a client’s strong ciphers and force a weak one without detection. TLS 1.3 closed that by having the server sign the whole transcript, so any tampering with the earlier cleartext messages shows up as a signature failure.
Finished closes the server’s flight: a MAC computed over the entire handshake transcript, keyed by a secret derived from the handshake traffic secret. It provides key confirmation, proves both sides derived the same keys, and binds the endpoint identity to those keys. In PSK mode it also authenticates the handshake outright. The client then verifies the server’s Finished, sends its own Certificate and CertificateVerify if and only if the server asked for client auth, and sends its own Finished. Once the server has the client’s Finished, the handshake is complete and both sides switch to the application traffic secrets.
*The reduction in passive visibility from 1.2 to 1.3. The certificate and the server's extensions go dark; SNI is the last cleartext field that names the destination, and ECH is closing that gap now.*So what does the encrypted block still leak to someone who cannot decrypt it? Less than the cleartext window, but not nothing. The boundaries between records are visible, so an observer sees the sizes and timing of each encrypted handshake record. The length of the encrypted Certificate message correlates with the size of the certificate chain, which is itself somewhat characteristic of a given server and CA. The number of round trips reveals whether a HelloRetryRequest happened. And the gap between ServerHello and the first application-data record carries a coarse signal about how much the server sent in its handshake flight. These are traffic-analysis signals, weak individually, and they are the reason TLS 1.3 added a record-padding mechanism that an implementation can use to blur record lengths. None of them give up the certificate contents or the negotiated ALPN the way 1.2 did.
The key schedule that makes the boundary real
The encryption boundary is not a flag in a header. It is a consequence of where each secret sits in the key schedule, so it is worth seeing the chain even at a sketch level. TLS 1.3 derives everything through HKDF, the extract-and-expand key derivation function, in a fixed ladder. Each rung is computed with HKDF-Extract or with a labelled HKDF-Expand, and the labels are ASCII strings written into the derivation so that two secrets derived from the same input but for different purposes never collide.
The ladder starts from a pre-shared key, or zeros if there is no PSK, and runs HKDF-Extract to get the Early Secret. From the Early Secret a Derive-Secret with a fixed “derived” label feeds the next extract, which mixes in the (EC)DHE shared secret to produce the Handshake Secret. From the Handshake Secret, two labelled expansions produce client_handshake_traffic_secret and server_handshake_traffic_secret, using the labels “c hs traffic” and “s hs traffic”. Those two secrets are the ones that protect EncryptedExtensions, Certificate, CertificateVerify, and Finished. Then another “derived” step feeds a final extract with a zero input to produce the Master Secret, and from the Master Secret come client_application_traffic_secret and server_application_traffic_secret, labels “c ap traffic” and “s ap traffic”, which protect application data.
This is the precise reason the boundary falls where it does. The (EC)DHE shared secret is the input that turns the Early Secret into the Handshake Secret, and that secret does not exist until both key shares are on the wire. The ClientHello carries the client’s share and the ServerHello carries the server’s. Only after the ServerHello can either side compute the Handshake Secret and from it the handshake traffic secrets. So the cleartext window is not a policy choice that could have been one message wider or narrower. It is exactly as wide as it takes to exchange the two key shares, and not a byte wider.
There is a 0-RTT wrinkle worth one paragraph. When a client resumes with a pre-shared key from an earlier session, it can derive a client_early_traffic_secret straight from the Early Secret, before any new shared secret exists, and use it to send encrypted application data in its very first flight. That is the famous 0-RTT early data, modelled on QUIC’s approach. The early data has weaker security properties than the rest of the connection, notably no forward secrecy and a replay exposure, which is why it is restricted to idempotent requests. For a fingerprinter, a ClientHello carrying a pre_shared_key and an early_data indication is a different shape from a fresh full handshake, and the TLS session-resumption fingerprint is its own signal worth tracking.
The last cleartext field, and where this is going
Walk away with the boundary in your head as three lines that nearly overlap but are not the same. The encryption boundary falls right after the ServerHello, fixed there by the key schedule. The fingerprinting surface is almost entirely the ClientHello, with a thin server-side fingerprint from the ServerHello, and the encrypted block contributes only weak traffic-analysis signals on top. The privacy boundary is the one still moving. Until recently it sat at the SNI: even with the certificate now encrypted, the plaintext server_name in the ClientHello told any observer which host the client wanted, which is how SNI-based filtering and a good deal of passive attribution kept working against TLS 1.3.
Encrypted Client Hello is closing that gap. ECH splits the ClientHello into an outer part that carries the non-sensitive parameters and a public-facing outer SNI, and an encrypted inner part that carries the real inner SNI and the rest. The encryption key is published in DNS, in HTTPS and SVCB resource records, which is how ECH escapes the chicken-and-egg problem of needing a secure channel to bootstrap a secure channel. Its predecessor ESNI, deployed experimentally by Cloudflare and Firefox in 2018, only encrypted the one field and never left draft; the IETF concluded that encrypting the whole ClientHello was the cleaner answer. With ECH live, a passive observer sees that a client is talking to a given provider but not which site behind it, and the fingerprintable inner ClientHello, the cipher list, the extensions, the JA4-worthy bytes, moves behind the curtain along with the SNI. That changes the post-quantum and ECH fingerprint surface in ways that are still settling.
Which leaves the trace looking different in a few years than it does today. The certificate went dark in 2018 with RFC 8446. The SNI is going dark now with ECH. What remains in cleartext on a fully ECH-protected connection is the outer ClientHello, a deliberately bland and uniform message that every client behind a given provider is meant to share. The whole arc of TLS 1.3 and its extensions has been to shrink the cleartext window without widening the round trips, and the window is now about as narrow as a key exchange can make it while still being a key exchange. For anyone who fingerprints handshakes, the useful bytes are migrating from the network into the endpoint, which is exactly where the rest of the detection industry already lives.
Sources & further reading
- Rescorla, E. / IETF (2018), RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3 — the normative spec; message flow in §2, ClientHello/ServerHello/HelloRetryRequest in §4.1, key schedule in §7.1.
- Sullivan, N. / Cloudflare (2018), A Detailed Look at RFC 8446 (a.k.a. TLS 1.3) — design rationale for 1-RTT, 0-RTT, full-transcript signing, and downgrade protection.
- Benton, K. & Bray, B. / IETF (2020), RFC 8701: Applying GREASE to TLS Extensibility — the reserved random code points clients sprinkle into the ClientHello to keep servers tolerant.
- FoxIO-LLC (2023–2026), JA4: TLS Client Fingerprinting — the sorted-hash fingerprint format and its field-by-field construction.
- Fastly (2023), Examining Chrome’s TLS ClientHello Permutation in the Wild — measurement of Chrome’s per-connection extension shuffle and its effect on JA3.
- Cloudflare (2023), Encrypted Client Hello — the last puzzle piece to privacy — how ECH splits the ClientHello and publishes keys in DNS.
- Center for Democracy and Technology (2024), Encrypted Client Hello: Closing the SNI Metadata Gap — the policy and traffic-analysis view of moving SNI behind the curtain.
- Peakhour (2024), JA3 vs. JA4 Fingerprinting: What’s New and Why It Matters — a practitioner comparison of the two fingerprint families and their failure modes.
- IBM (2024), The TLS 1.3 Protocol — implementer’s-eye view of the handshake states from the OpenJDK side.
Further reading
How Cloudflare uses TLS and HTTP/2 fingerprints in bot scoring
A reference on Cloudflare's network-layer fingerprinting: how JA3, JA4, and the HTTP/2 frame profile are computed at the edge, what cf.bot_management exposes, and how those signals feed the 1-99 bot score.
·23 min readTLS fingerprinting: from ClientHello bytes to JA4
What a ClientHello actually contains, why JA3 worked for six years and then stopped, and what JA4 fixes, with a Python reference you can run against your own packet captures.
·15 min readThe TLS ClientHello, field by field: a fingerprinting reference
A field-by-field dissection of the TLS ClientHello, tracing exactly which bytes JA3 and JA4 read: version, cipher suites, compression, extensions, supported_groups, signature_algorithms, supported_versions, key_share, and ALPN.
·19 min read