RFC 8446 is not TLS 1.2 with better ciphers. It deleted static RSA key transport, renegotiation, compression, custom DH groups, and every non-AEAD cipher, then moved the entire server flight behind encryption one message after ServerHello. What is left on the wire in cleartext is a ClientHello and a ServerHello - which is exactly why those two messages became the fingerprinting surface that JA4+ reads. Everything below decodes one real 517-byte Chrome-shaped ClientHello to example.com, byte for byte, and the JA4 tab shows precisely which of those bytes get hashed. Hover a field to light its bytes, click a field marked + for its lookup table.
Every TLS byte on the wire sits inside a record: one byte of type, two bytes of legacy version, two bytes of length. That header is the AEAD's additional data, so it is authenticated but never confidential - the length of every encrypted record is public, forever.
| struct | bytes | when |
|---|---|---|
| TLSPlaintext | type · 0x0301/0x0303 · len · fragment | ClientHello, ServerHello, alerts before keys, and the dummy ChangeCipherSpec. type is the real content type. |
| TLSCiphertext | 0x17 · 0x0303 · len · encrypted_record | Everything else. opaque_type is always application_data (23), whatever is actually inside. |
Limits: TLSPlaintext.length must not exceed 2^14. TLSCiphertext.length gets 256 bytes of slack (2^14 + 256) for the content-type byte, padding, and the AEAD tag. A record with a longer length is a record_overflow alert, not a parse.
Five clear bytes, then opaque. Offsets in every inspector on this page are relative to the block shown.
The AEAD covers TLSInnerPlaintext: the real content, then the real content type, then any zero padding. The receiver decrypts, scans backwards past the zeros, and the first non-zero byte it hits is the content type.
0x17 on the outside. That also gives padding for free: append zeros before encrypting and record lengths stop leaking exact message sizes. Scan backwards from the end - a receiver that scans forward for the first byte that "looks like" a type is parsing attacker-controlled plaintext.TLS 1.3 has no CCS. This record exists purely so that network middleboxes hard-coded against the TLS 1.2 handshake shape see something familiar after the hello and stop mangling the connection. The client sends it after its ClientHello, the server after its ServerHello. A 1.3 endpoint must ignore it, and it is not part of the handshake transcript.
0x0301 in the initial ClientHello record, the 32-byte fake session ID, the CCS above, and the 0x0303 in legacy_version all exist because a deployed box somewhere drops what it does not recognise. The real version negotiation was moved into an extension precisely because the version field was unusable. This is why 1.3 is the version that shipped as "TLS 1.2 with extensions" on the wire.The first 114 bytes: record header, handshake header, and every fixed field of the ClientHello up to the extensions. Three of the five fields here are dead, kept only so the message still parses as TLS 1.2 to anything watching.
Client at 192.0.2.10 opening to example.com. Cipher list, extension set, and ordering are a real Chrome profile - the JA4 tab hashes exactly these bytes.
legacy_version is frozen at 0x0303. A ClientHello offering TLS 1.3 still says "TLS 1.2" here, because clients that put 0x0304 in this field in 2016 found servers that simply dropped the connection. The real answer is in the supported_versions extension at offset 277, and a 1.3 server must look there first and ignore this field. Same story in reverse for ServerHello.legacy_version.legacy_session_id is legal and is what a 1.3-only stack that has given up on compatibility mode sends.X25519MLKEM768 share is 1216 bytes, which is what forced the ClientHello to start spanning multiple TCP segments and broke another round of middleboxes in 2024.TLS 1.3 put its actual protocol in extensions. Version negotiation, key exchange, and authentication are all extension payloads hanging off a message whose fixed fields are frozen for compatibility. The four that matter are below; the roster at the end is the full 403 bytes in wire order.
A 1-byte list length, then 2-byte versions in client preference order. The server picks the first one it supports and echoes a single version back.
legacy_version. Highest non-GREASE value wins: 0x0304 becomes 13. A client that offers 1.3 and gets downgraded still fingerprints as a 1.3-capable client, which is the point.The client sends a real (EC)DHE public key before the server has said which group it wants. Guess right and the handshake is 1-RTT; guess wrong and it costs a HelloRetryRequest.
supported_groups.Groups the client will do (EC)DHE over, then the signature schemes it will accept in a certificate and in CertificateVerify. Both lists carry GREASE.
0x04xx entries are certificate-only. rsa_pkcs1_sha256 and friends are legal to advertise so a server can still present an RSA PKCS#1 certificate chain, but RFC 8446 §4.2.3 forbids them in CertificateVerify - a 1.3 handshake signs with RSA-PSS or ECDSA. JA4 keeps this list in wire order rather than sorting it, because the order is a stable property of the TLS library rather than something a client shuffles.The two extensions JA4 counts but does not hash. Both are attacker-visible plaintext in a 1.3 handshake.
0xfe0d, still a draft) fixes it by encrypting an inner ClientHello to a public key fetched from DNS, which relocates the trust problem rather than deleting it.Offsets are from byte 0 of the record. JA4 marks whether each extension is counted, hashed, or stripped.
| off | len | type | name | ja4 | role in TLS 1.3 |
|---|---|---|---|---|---|
| 114 | 4 | 0x3a3a | GREASE | stripped | RFC 8701 poison pill. Servers must ignore it. |
| 118 | 20 | 0x0000 | server_name | counted only | RFC 6066. The certificate selector. Plaintext. |
| 138 | 4 | 0x0017 | extended_master_secret | counted + hashed | RFC 7627. Dead in 1.3 - the key schedule already binds the transcript. |
| 142 | 5 | 0xff01 | renegotiation_info | counted + hashed | RFC 5746. Dead in 1.3 - there is no renegotiation. |
| 147 | 14 | 0x000a | supported_groups | counted + hashed | Live. (EC)DHE groups, and FFDHE groups too since 1.3. |
| 161 | 6 | 0x000b | ec_point_formats | counted + hashed | RFC 8422. Dead in 1.3 - only uncompressed points exist. |
| 167 | 4 | 0x0023 | session_ticket | counted + hashed | RFC 5077. Dead in 1.3 - superseded by PSK. |
| 171 | 18 | 0x0010 | ALPN | counted only | RFC 7301. Live. Picks h2 vs http/1.1. Plaintext in the hello. |
| 189 | 9 | 0x0005 | status_request | counted + hashed | RFC 6066. Live - OCSP stapling, now carried per-certificate. |
| 198 | 22 | 0x000d | signature_algorithms | counted + hashed | Live. Mandatory whenever certificate auth is used. |
| 220 | 4 | 0x0012 | signed_certificate_timestamp | counted + hashed | RFC 6962. Live. Certificate Transparency proofs. |
| 224 | 47 | 0x0033 | key_share | counted + hashed | Live, and load-bearing. The (EC)DHE public key. |
| 271 | 6 | 0x002d | psk_key_exchange_modes | counted + hashed | Live. psk_dhe_ke(1) here - resumption that stays forward secret. |
| 277 | 11 | 0x002b | supported_versions | counted + hashed | Live. The actual version negotiation. |
| 288 | 7 | 0x001b | compress_certificate | counted + hashed | RFC 8879. Live. Brotli here - shrinks the server's cert chain. |
| 295 | 9 | 0x4469 | application_settings | counted + hashed | Chrome's ALPS draft. Not an RFC - and a strong Chrome tell. |
| 304 | 208 | 0x0015 | padding | counted + hashed | RFC 7685. Pads the message to 512 bytes. |
| 512 | 5 | 0xdada | GREASE | stripped | Second poison pill, at the end of the list. |
0x0a0a, 0x1a1a, ... 0xfafa are advertised in cipher suites, extensions, groups, versions, ALPN, and PSK modes. They mean nothing, and a peer that fails on an unknown value gets caught in testing rather than blocking the next real extension. Both JA3 and JA4 strip them before counting or hashing - a fingerprint that included GREASE would change on every connection, which is exactly what GREASE is for.extended_master_secret, renegotiation_info, ec_point_formats, session_ticket, and both GREASE entries are inert in a 1.3 handshake. They are sent because the same ClientHello has to work against a TLS 1.2 server, and browsers only get one hello. That dead weight is a large part of what JA4 is actually fingerprinting: not what the client is doing, but which library decided what to keep.The last plaintext message of the handshake. It carries exactly two live extensions - the chosen version and the server's key share - because after this message both sides can derive handshake keys, and everything else moves inside EncryptedExtensions.
One cipher suite, no compression, and a session ID echoed back byte-for-byte from the client's 32 bytes of noise.
TLS_AES_128_GCM_SHA256 is only an AEAD plus a hash for HKDF. Key exchange and authentication are gone from the name because they are negotiated separately, in supported_groups/key_share and signature_algorithms. That is why there are five 1.3 suites total instead of hundreds - the combinatorial explosion of ECDHE_RSA_WITH_... was a bug, not a feature.If the client's key share used a group the server will not do, the server does not error. It sends a ServerHello whose Random is a fixed magic constant.
The client checks ServerHello.random against that constant before parsing anything else. On a match it re-sends a ClientHello with a share for the group the server named, and the handshake costs 2-RTT.
message_hash message: 0xfe, a 3-byte length, then Hash(ClientHello1). Every later Derive-Secret runs over that stand-in, not over the original bytes. Implementations that forget this produce a handshake that verifies fine until the first HRR in production.| lands in | extension | why there |
|---|---|---|
| ServerHello | supported_versions, key_share, pre_shared_key | Needed to derive keys. Must be readable before encryption exists. |
| EncryptedExtensions | ALPN, server_name ack, early_data, record_size_limit, supported_groups | Not needed for key derivation, so 1.3 hides them. The negotiated protocol is no longer visible on the wire. |
| Certificate | status_request, signed_certificate_timestamp | Per-certificate in 1.3, attached to the CertificateEntry rather than sent as separate handshake messages. |
EncryptedExtensions, so an observer cannot see whether a connection negotiated h2 or http/1.1 from the server side. But the client's ALPN offer is still plaintext in the ClientHello at offset 171 - which is why JA4's a segment can read h2 off the wire, and why traffic analysis of TLS 1.3 still works from the client's half of the handshake alone.Braces mean encrypted under handshake keys, brackets mean application keys. The client can send application data after one round trip; the server can start after half of one.
Messages marked * are situational: client certificates only appear if the server sent a CertificateRequest. In a PSK handshake, Certificate and CertificateVerify vanish from both sides - the PSK is the authentication.
0x20 bytes, then the context string "TLS 1.3, server CertificateVerify", then 0x00, then the transcript hash. The 64-space prefix stops a signature from one context being replayed into another - the same trick that fixed cross-protocol attacks in earlier designs.HMAC(finished_key, Transcript-Hash(everything so far)), where finished_key = HKDF-Expand-Label(handshake_traffic_secret, "finished", "", Hash.length). This is what makes downgrade and modification of any earlier byte detectable.ServerHello.random: 44 4F 57 4E 47 52 44 01 for TLS 1.2, ...00 for 1.1 and below. The client checks for it, and because the random is covered by the 1.2 Finished MAC, an attacker who strips the 1.3 offer cannot forge it. Downgrade protection built out of a field that already existed.Three HKDF-Extract steps chained through a Derive-Secret between each. Every secret is bound to a transcript hash, so a secret derived over a modified handshake is a different secret and the Finished MAC fails.
The vertical arrow into each Extract is the salt; the horizontal arrow is the input keying material. Reversing the two is a silent interop failure that only shows up against another implementation. With no PSK, the PSK input is Hash.length zero bytes.
Every expansion serialises its label into this struct first. Shown: deriving the server handshake traffic secret, 32 output bytes, with a SHA-256 transcript hash as context.
"tls13 " prefix is domain separation. Six bytes prepended to every label mean a TLS 1.3 key schedule can never collide with any other protocol that reuses HKDF with the same secret. The 1-byte label length caps a label at 255 bytes, and the 1-byte context length caps the transcript hash - which is why 1.3 cannot use a hash longer than 255 bytes and nobody has ever noticed.HKDF-Expand-Label(secret, "key", "", key_length) - 16 bytes for AES-128-GCM, 32 for AES-256-GCM and ChaCha20.HKDF-Expand-Label(secret, "iv", "", 12) - a static IV, not a nonce.write_iv. The sequence resets to zero on every key change, and is never transmitted.0x17 0x03 0x03 len_hi len_lo. Authenticated, not encrypted.secret_{N+1} = HKDF-Expand-Label(secret_N, "traffic upd", "", Hash.length), triggered by a KeyUpdate message. One-way: an attacker with secret_N gets every future secret, so this bounds the damage of a leaked key going forward in time, not backward.After a handshake, the server hands out tickets. On the next connection the client can send application data in its very first flight, encrypted under a key derived from the ticket alone. It is the fastest thing in TLS and the only part of 1.3 with a security caveat printed in the spec.
The PSK itself never crosses the wire: resumption_psk = HKDF-Expand-Label(resumption_master_secret, "resumption", ticket_nonce, Hash.length). The ticket is an opaque handle - either a database key or the PSK sealed under a server-side key.
Identity plus obfuscated age, then a binder. This extension must be the last one in the ClientHello, and it is the only extension in TLS with an ordering requirement.
pre_shared_key were anywhere else, the truncation point would fall in the middle of the message and every extension after it would be unauthenticated. Placing it last makes "everything before the binder" and "the whole message minus the binder" the same span of bytes.psk_dhe_ke(1) is the one you want. Plain psk_ke(0) resumes with no fresh Diffie-Hellman at all, so the session key depends only on the ticket - compromise the ticket key and every session resumed under it decrypts. With psk_dhe_ke the resumed handshake still mixes a fresh (EC)DHE share into the Handshake Secret, and forward secrecy survives. Browsers send 01.EndOfEarlyData is encrypted under the early traffic keys, not the handshake keys, and is only sent if the server accepted. A rejecting server just omits early_data from EncryptedExtensions; the client discards the early keys and replays its data over the completed 1-RTT connection.
| property | 1-RTT | 0-RTT |
|---|---|---|
| forward secrecy | yes, fresh (EC)DHE | no - keyed from the ticket alone |
| replay protection | yes, from the server random | not from the protocol |
| server can reject | n/a | yes, silently, client replays |
RFC 8446 §8 gives three defences, and expects you to pick one: single-use tickets, a server-side record of accepted ClientHellos within a time window, or freshness checks against obfuscated_ticket_age. None of them are the protocol doing it for you.
GET /index.html and catastrophic for POST /transfer. The rule that actually holds: only ever put idempotent requests in early data, and treat the ticket age check as a rate limiter rather than a guarantee. Deployments that hand 0-RTT to a generic HTTP handler have shipped a replay vulnerability, not a latency optimisation.TLS 1.3 encrypted the handshake, and the ClientHello is what was left behind. JA4 is the fingerprint computed from exactly the fields decoded above - hover a segment to see which bytes it came from.
Reproduced from the bytes on this page. This is FoxIO's published Chrome vector, which is a useful check that the hello above is shaped like a real one and not a plausible-looking invention.
| ja4 segment | bytes on the ClientHello | treatment |
|---|---|---|
| t | not in the hello | Transport, from the layer below. t TCP, q QUIC, d DTLS. |
| 13 | 277-287 | supported_versions, highest non-GREASE value. Not legacy_version at byte 9. |
| d | 118-137 | server_name present. i if absent, i.e. a bare-IP connection. |
| 15 | 78-109 | 16 suites in 32 bytes, minus the GREASE at byte 78 = 15. |
| 16 | 114-516 | 18 extensions, minus two GREASE = 16. SNI and ALPN are counted here. |
| h2 | 171-188 | First and last character of the first ALPN value. http/1.1 would give h1. |
| _b | 80-109 | The 15 non-GREASE suites, sorted ascending, hashed. |
| _c | 114-516 + 200-221 | 14 extension IDs (16 counted, minus SNI and ALPN), sorted, then _ and the 8 sigalgs in wire order. |
server_name at 118 and ALPN at 171. The extension hash is computed over 14 - those two are removed first. They are counted because their presence is a stable client property, and removed from the hash because their values are per-destination: hashing them would give a different fingerprint for every hostname a browser visits.tls.ClientHelloInfo: the library has already stripped GREASE, lost extension order, and dropped what it does not model. See the JA4+ breakdown for the rest of the family, and eBPF in the network stack for reading these bytes before the kernel hands them up.