PROTOCOLS-FUN . TLS 1.3

TLS 1.3 Handshake

RFC 8446
rev 2026.08
handshake 1-RTT · 0-RTT on resume
key exchange (EC)DHE only, always forward secret
encrypted from the message after ServerHello

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.

record header5 bytes, never encryptedspecRFC 8446 §5

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.

the two record types
structbyteswhen
TLSPlaintexttype · 0x0301/0x0303 · len · fragmentClientHello, ServerHello, alerts before keys, and the dummy ChangeCipherSpec. type is the real content type.
TLSCiphertext0x17 · 0x0303 · len · encrypted_recordEverything 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.

an encrypted record on the wire

Five clear bytes, then opaque. Offsets in every inspector on this page are relative to the block shown.

what was inside it

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.

the content type moved inside the encryption. In TLS 1.2 an eavesdropper could see handshake records, alert records, and data records as distinct types. In 1.3 they are all 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.
the dummy ChangeCipherSpec
// middlebox compatibility mode, RFC 8446 Appendix D.4 14 03 03 00 01 01 ChangeCipherSpec, length 1, value 1

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.

every legacy field in TLS 1.3 is scar tissue from a middlebox. The 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.
record517 bytes totalhandshake msg512 bytesspecRFC 8446 §4.1.2

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.

ClientHello, record byte 0 through 113

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.
the session ID is 32 bytes of noise that means nothing. TLS 1.3 resumes with PSK tickets, not session IDs, so this field has no protocol function at all. Clients fill it with 32 random bytes and servers echo it back verbatim, purely so the exchange looks like a TLS 1.2 session resumption to a middlebox. An empty legacy_session_id is legal and is what a 1.3-only stack that has given up on compatibility mode sends.
why 517 bytes. The padding extension (offset 304, 208 bytes) pushes the handshake message to exactly 512 bytes, 517 with the record header. This is not privacy padding - it is RFC 7685 working around F5 load balancers that choke on ClientHellos between 256 and 511 bytes. The 517-byte ClientHello is now so universal it is itself a fingerprint, and a post-quantum key share blows straight past it: an 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.
in this hello18 extensions, 403 bytescounted by JA416 (GREASE stripped)

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.

supported_versions (43) - the real version field

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.

JA4 reads its version from here, not from 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.
key_share (51) - the client guesses

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.

this is where the round trip was saved. TLS 1.2 needed ServerHello and ServerKeyExchange before the client could contribute a DH share. TLS 1.3 speculates: send x25519 up front, because the server almost certainly supports it. The cost of being wrong is a full extra round trip, so clients send one share for the group they expect and list the rest in supported_groups.
supported_groups (10) and signature_algorithms (13)

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.

the 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.
server_name (0) and ALPN (16)

The two extensions JA4 counts but does not hash. Both are attacker-visible plaintext in a 1.3 handshake.

SNI is the last plaintext identity in TLS 1.3. Everything else about the connection moved behind encryption, and then the hostname stayed in the clear at offset 118 because the server needs it to pick a certificate. That single field is what national-scale filtering keys on. Encrypted Client Hello (extension 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.
the full roster, wire order

Offsets are from byte 0 of the record. JA4 marks whether each extension is counted, hashed, or stripped.

offlentypenameja4role in TLS 1.3
11440x3a3aGREASEstrippedRFC 8701 poison pill. Servers must ignore it.
118200x0000server_namecounted onlyRFC 6066. The certificate selector. Plaintext.
13840x0017extended_master_secretcounted + hashedRFC 7627. Dead in 1.3 - the key schedule already binds the transcript.
14250xff01renegotiation_infocounted + hashedRFC 5746. Dead in 1.3 - there is no renegotiation.
147140x000asupported_groupscounted + hashedLive. (EC)DHE groups, and FFDHE groups too since 1.3.
16160x000bec_point_formatscounted + hashedRFC 8422. Dead in 1.3 - only uncompressed points exist.
16740x0023session_ticketcounted + hashedRFC 5077. Dead in 1.3 - superseded by PSK.
171180x0010ALPNcounted onlyRFC 7301. Live. Picks h2 vs http/1.1. Plaintext in the hello.
18990x0005status_requestcounted + hashedRFC 6066. Live - OCSP stapling, now carried per-certificate.
198220x000dsignature_algorithmscounted + hashedLive. Mandatory whenever certificate auth is used.
22040x0012signed_certificate_timestampcounted + hashedRFC 6962. Live. Certificate Transparency proofs.
224470x0033key_sharecounted + hashedLive, and load-bearing. The (EC)DHE public key.
27160x002dpsk_key_exchange_modescounted + hashedLive. psk_dhe_ke(1) here - resumption that stays forward secret.
277110x002bsupported_versionscounted + hashedLive. The actual version negotiation.
28870x001bcompress_certificatecounted + hashedRFC 8879. Live. Brotli here - shrinks the server's cert chain.
29590x4469application_settingscounted + hashedChrome's ALPS draft. Not an RFC - and a strong Chrome tell.
3042080x0015paddingcounted + hashedRFC 7685. Pads the message to 512 bytes.
51250xdadaGREASEstrippedSecond poison pill, at the end of the list.
GREASE (RFC 8701) is a vaccine against ossification. The sixteen reserved values 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.
ten of the eighteen extensions here do nothing. 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.
ServerHello127 bytes, cleartexttheneverything is encrypted

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.

ServerHello, the whole record

One cipher suite, no compression, and a session ID echoed back byte-for-byte from the client's 32 bytes of noise.

a 1.3 cipher suite names less than a 1.2 one. 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.
HelloRetryRequest - a ServerHello in disguise

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.

// SHA-256("HelloRetryRequest") - RFC 8446 4.1.3 CF 21 AD 74 E5 9A 61 11 BE 1D 8C 02 1E 65 B8 91 C2 A2 11 16 7A BB 8C 5E 07 9E 09 E2 C8 A8 33 9C

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.

HRR rewrites the transcript. To keep the hash a fixed size and stop an attacker from replaying flights, the first ClientHello is replaced in the transcript by a synthetic 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.
EncryptedExtensions - the first encrypted message
lands inextensionwhy there
ServerHellosupported_versions, key_share, pre_shared_keyNeeded to derive keys. Must be readable before encryption exists.
EncryptedExtensionsALPN, server_name ack, early_data, record_size_limit, supported_groupsNot needed for key derivation, so 1.3 hides them. The negotiated protocol is no longer visible on the wire.
Certificatestatus_request, signed_certificate_timestampPer-certificate in 1.3, attached to the CertificateEntry rather than sent as separate handshake messages.
ALPN went dark, SNI did not. The server's ALPN choice is inside 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.
full handshake1-RTTresumption0-RTTmismatched group2-RTT

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.

the 1-RTT handshake
client server
ClientHellokey_share, supported_versions, sigalgs, SNI, ALPNcleartext
cleartextServerHellokey_share, supported_versions
handshake keys{EncryptedExtensions}
{CertificateRequest*}
{Certificate}
{CertificateVerify}
{Finished}server is authenticated as of CertificateVerify
app keys[Application Data]0.5-RTT data, sent before the client is authenticated
{Certificate*}
{CertificateVerify*}
{Finished}handshake keys
1-RTT complete - both directions on application keys
[Application Data]app keys
app keys[Application Data]

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.

what proves what
CertificateVerifysignature over 64 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.
FinishedHMAC(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.
the transcriptConcatenated handshake messages - type byte, 3-byte length, body - with record headers excluded and the dummy ChangeCipherSpec excluded. Getting this boundary wrong is the single most common way a from-scratch implementation fails at Finished.
0.5-RTT data is a real trap. The server may send application data immediately after its own Finished, before it has seen the client's. At that point the client is not authenticated - the server does not know whether the peer that will complete the handshake is the one it thinks. RFC 8446 §2.2 is explicit that a server sending 0.5-RTT data must not assume client authentication. Anything gated on a client certificate has to wait for the client's Finished.
the downgrade sentinel. A 1.3-capable server that negotiates 1.2 or below writes a fixed 8-byte value into the last 8 bytes of 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.
primitiveHKDF (RFC 5869)stages3 extractsspecRFC 8446 §7.1

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 schedule
0 | v PSK -> HKDF-Extract = Early Secret | +-> Derive-Secret(., "ext binder" | "res binder", "") = binder_key +-> Derive-Secret(., "c e traffic", ClientHello) = client_early_traffic_secret +-> Derive-Secret(., "e exp master", ClientHello) = early_exporter_master_secret | v Derive-Secret(., "derived", "") | v (EC)DHE -> HKDF-Extract = Handshake Secret | +-> Derive-Secret(., "c hs traffic", CH..SH) = client_handshake_traffic_secret +-> Derive-Secret(., "s hs traffic", CH..SH) = server_handshake_traffic_secret | v Derive-Secret(., "derived", "") | v 0 -> HKDF-Extract = Master Secret | +-> Derive-Secret(., "c ap traffic", CH..server Fin) = client_application_traffic_secret_0 +-> Derive-Secret(., "s ap traffic", CH..server Fin) = server_application_traffic_secret_0 +-> Derive-Secret(., "exp master", CH..server Fin) = exporter_master_secret +-> Derive-Secret(., "res master", CH..client Fin) = resumption_master_secret

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.

HkdfLabel, on the wire

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.

the "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.
from secret to record keys
write_keyHKDF-Expand-Label(secret, "key", "", key_length) - 16 bytes for AES-128-GCM, 32 for AES-256-GCM and ChaCha20.
write_ivHKDF-Expand-Label(secret, "iv", "", 12) - a static IV, not a nonce.
nonce64-bit record sequence number, left-padded with zeros to 12 bytes, XORed with write_iv. The sequence resets to zero on every key change, and is never transmitted.
additional dataThe 5-byte record header exactly as it appears on the wire: 0x17 0x03 0x03 len_hi len_lo. Authenticated, not encrypted.
key updatesecret_{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.
every session key depends on a fresh (EC)DHE share, with no exceptions. Deleting static RSA key transport is the single biggest change in TLS 1.3: there is no longer any mode where recording traffic today and stealing the server's private key later decrypts it. That also broke passive enterprise inspection outright, which is why "eTLS"/ETS was proposed and rejected - it worked by putting a static key back, which is precisely the thing that was removed.
0-RTTdata with the ClientHellocostreplayable, not forward secretspecRFC 8446 §2.3, §8

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.

NewSessionTicket - the server issues a PSK

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.

pre_shared_key (41) - the client presents it

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.

the binder is why it must be last. The binder is an HMAC over the ClientHello truncated immediately before the binders list. If 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_key_exchange_modes (45)
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.
the 0-RTT flight
client server
ClientHello+ early_data, pre_shared_key (last)cleartext
(Application Data*)0-RTT: replayable, not forward secretearly keys
cleartextServerHello+ pre_shared_key
handshake keys{EncryptedExtensions + early_data}
{Finished}the early_data ack here is the accept signal
{EndOfEarlyData}
{Finished}handshake keys
from here, ordinary 1-RTT application keys

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.

what 0-RTT costs
property1-RTT0-RTT
forward secrecyyes, fresh (EC)DHEno - keyed from the ticket alone
replay protectionyes, from the server randomnot from the protocol
server can rejectn/ayes, 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.

0-RTT moves a guarantee into your application. An attacker who captures a 0-RTT flight can resend it verbatim, and it will decrypt. That is fine for 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.
inputthe 517 bytes on the ClientHello taboutputsha256[:12] x2siblingJA4+ breakdown

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.

the exact inputs

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.

// hash b: sorted, GREASE stripped, from the cipher list at offset 78 002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9 sha256[:12] -> 8daaf6152771 // hash c: sorted extensions, GREASE + SNI + ALPN removed, then sigalgs in WIRE order 0005,000a,000b,000d,0012,0015,0017,001b,0023,002b,002d,0033,4469,ff01 _0403,0804,0401,0503,0805,0501,0806,0601 sha256[:12] -> e5627efa2ab1
byte ranges JA4 touches
ja4 segmentbytes on the ClientHellotreatment
tnot in the helloTransport, from the layer below. t TCP, q QUIC, d DTLS.
13277-287supported_versions, highest non-GREASE value. Not legacy_version at byte 9.
d118-137server_name present. i if absent, i.e. a bare-IP connection.
1578-10916 suites in 32 bytes, minus the GREASE at byte 78 = 15.
16114-51618 extensions, minus two GREASE = 16. SNI and ALPN are counted here.
h2171-188First and last character of the first ALPN value. http/1.1 would give h1.
_b80-109The 15 non-GREASE suites, sorted ascending, hashed.
_c114-516 + 200-22114 extension IDs (16 counted, minus SNI and ALPN), sorted, then _ and the 8 sigalgs in wire order.
the count/hash asymmetry lives right here. The extension count reads 16 - it includes 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.
sorted vs wire order, byte by byte. The cipher list (78-109) and the extension IDs are sorted before hashing, because Chrome shuffles both per connection and JA3 died over it. The signature algorithms at 200-221 stay in wire order, because no client randomises them and the order is a property of the library build. Same rule everywhere in JA4+: canonicalise what an evader can cheaply randomise, preserve what the stack fixes.
this fingerprint is derived from a struct that no longer needs to exist. Of the 16 counted extensions, 6 are inert in TLS 1.3 and 208 bytes of the message are padding for a load balancer bug. JA4 is stable precisely because that legacy baggage is stable - a client is identified by the shape of its compatibility compromises. Compute it from the wire and not from a parsed 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.