TLS Resumption, ALPN, GREASE & HelloRetryRequest
The Second Handshake Is the Tell
The TLS & JA3 Spoofing lesson covers the first ClientHello: its cipher list, its extension order, the hash a WAF computes over them. That is the obvious half. The other half is everything that happens after it: whether the client came back with a session ticket, which application protocol it negotiated, where it sprinkled reserved values, and what happened when the server changed its mind about curves.
None of these are secrets. They are behaviour, and behaviour is what a WAF models. A scraper that opens a new TCP connection per request and therefore does a full handshake every time is not sending a subtly wrong ClientHello; it is sending a population of clients that no browser has ever produced.
Session Tickets: State the Client Carries
In TLS 1.2 a server could resume by remembering a session ID, which meant the server held state per client. TLS 1.3 removed that option. The server now sends a NewSessionTicket immediately after the handshake finishes, on the same connection, and the ticket is an opaque encrypted blob the client stores. On the next connection to that host, the client sends it back in a pre_shared_key extension, and the server decrypts it, recovers the previous handshake's secret material, and skips the certificate exchange entirely.
The ticket is opaque, which is the point: the client cannot read it, cannot forge one, and cannot tell a server's ticket from another server's. It is also bound to the connection that received it in most implementations, which is why a client that stores tickets per host is behaving differently from one that does not.
| Full handshake | Resumption (abbreviated) | |
|---|---|---|
| Round trips | 1 (TLS 1.3) | 1, or 0 with 0-RTT |
| Certificate sent | yes | no |
key_share in ClientHello |
a real public key | usually empty |
pre_shared_key in ClientHello |
absent | present, must be last |
| Client-visible cost | asymmetric crypto | one decryption |
The key_share extension is the giveaway in the other direction. A ClientHello carrying pre_shared_key with an empty key_share list is a resumption: the client has no ephemeral key to contribute because it is not doing ECDHE. The TLS Handshake covers the message flow; the parser at the end of this lesson prints both shapes.
Why Connection-per-Request Is Instantly Unusual
A browser visiting a page opens one TLS connection, then reuses it for every subresource on that origin, then keeps it in the pool for the next navigation. Opening a connection, doing one request, and closing it is a behaviour that exists almost exclusively in scripts. A WAF that tracks per-IP connection reuse can flag it without reading a single header, and the flag is worth more than any JA3 mismatch because it cannot be patched in the request layer at all.
The practical consequence for a scraper: session reuse has to be real. A pooled curl_cffi session, an httpx.Client with keep-alive, or a warm browser context all do it. A fresh requests.get() in a loop does not, and no amount of header work fixes that.
ALPN: The Order Is the Fingerprint
ALPN (RFC 7301) lets the client offer application protocols in the ClientHello and lets the server pick one. Every browser offers the same two entries, in this order:
h2, http/1.1
The order is the client's preference, and servers honour it. But WAFs also hash the list, so http/1.1, h2 is a different fingerprint from h2, http/1.1 even though both negotiate HTTP/2 with any cooperating server. A client that offers only http/1.1 is unusual in the same way as a cipher list with no TLS 1.3 suite: it looks like a library, not a browser. curl with HTTP/2 disabled, and most Python HTTP clients, land in this bucket.
ALPN is worth one specific defence: a client that claims to be Chrome 124 and never offers h2 has contradicted itself in a field the browser cannot avoid setting.
GREASE: Reserved Values as a Compatibility Probe
RFC 8701 defines GREASE ("Generating Random Extensions And Sustain Extensibility"). The idea is that a client inserts reserved values into every extensible list, so that servers which mishandle unknown values fail visibly in testing rather than in production.
Chrome puts them everywhere. In the ClientHello you will find a GREASE cipher suite, usually first in the list; a GREASE extension number; a GREASE value inside supported_groups; and a GREASE value inside supported_versions. The value is one of a fixed set where the two bytes are equal and of the form 0x?a?a, which is how a collector recognises it.
This creates a subtlety for fingerprinting that the JA3 discussion does not reach. A naive hash over the raw lists would change every time the client picked a different reserved value, so every WAF strips GREASE before hashing. But stripping it destroys information, and a good collector keeps the pattern: which list had a reserved value, at which index, and how many. A ClientHello whose GREASE value appears in the cipher list but nowhere in the groups is a shape no shipped browser has.
So the effective check is not "does it contain 0x0a0a" but "is the GREASE pattern the one this browser version produces". Reordering a cipher list to match a known JA3 while leaving the GREASE positions untouched produces a fingerprint that matches no real client, because you matched the part that gets hashed and ignored the part that gets compared structurally.
HelloRetryRequest and the key_share Trap
A TLS 1.3 client optimises for one round trip by guessing which curve to use for its key share. If the server does not support the guessed group, it replies with a HelloRetryRequest, a ServerHello-shaped message with a special random value, naming a group the client should retry with. The client sends a second ClientHello with a key_share for that group and a supported_versions extension but nothing else that has already been processed.
The important limit: one HelloRetryRequest per handshake. RFC 8446 section 4.1.4 says a client that receives a second one must abort. A server that sends two is broken; a client library that has been patched into a state where it can be triggered twice is a signal in itself, because no browser can be.
A WAF watching for curve negotiation watches the pair of curves across the two ClientHellos and the group the server asked for. Chrome, Firefox and Safari guess differently, and their supported_groups lists change between versions: x25519 first is current everywhere, with the P-256, P-384 and P-521 fallbacks behind it, and a GREASE value in front of all of that. A client whose group list is secp256k1 first, or which offers only P-256, is a library.
Encrypted Client Hello
ECH (Encrypted Client Hello, draft-ietf-tls-esni) encrypts the SNI and the sensitive inner extensions inside a public extension, using a key the client fetches from DNS over HTTPS. The outer ClientHello that reaches the server then carries a cover name, and the real hostname is visible only to the server that holds the private half.
For a defender this is a mixed result. ECH removes the SNI from passive observation, which is a real privacy gain. But it also means the outer ClientHello is now generated by a different code path with its own shapes, and the client's willingness to use ECH, visible as the encrypted_client_hello extension (0xfe0d) with a specific outer length, is itself a version signal. A client that advertises ECH with no public_name extension from the DNS HTTPS record, or with a cover name inconsistent with the SNI it is connecting to, has done something no browser does.
Tickets Have a Lifetime, and Profiles Forget Them
Session tickets are not permanent. A server sets a ticket lifetime in the NewSessionTicket message, typically a few hours to seven days, and a key rotation schedule behind it. Chrome, Firefox and Safari all cap how long a stored ticket may be used for resumption regardless of what the server allows, commonly around seven days, and delete the cache on exit in some configurations.
The operational consequence is the one that bites in practice: a headless browser restarted per job throws its ticket cache away. If your pipeline launches a fresh browser context for every URL, every one of those connections is a full handshake, and you have built the connection-per-request pattern at a much higher cost. This is why a browser pool is not a speed optimisation only; it is a behavioural requirement, and it is covered in Browser Pool Architecture.
The Same Browser, Two Operating Systems, Two Fingerprints
The ClientHello is produced by the TLS stack, and the stack is not the browser:
| Stack | Ships with | Notable ClientHello traits |
|---|---|---|
| BoringSSL | Chrome, Chromium, Edge, Electron | GREASE in four lists, compress_certificate, record_size_limit |
| NSS | Firefox | no GREASE by default, different extension order, post_handshake_auth |
| Secure Transport | Safari, macOS, iOS | no GREASE, signature_algorithms in Apple order, key_share for P-256 and P-384 |
| Schannel | Windows system components | very short cipher list, no GREASE, renegotiation_info present |
| OpenSSL | curl, Python ssl, most servers |
configurable; a default build is instantly recognisable |
So the same Chrome version on Windows and on macOS produces two different JA3s, and both are legitimate. A WAF therefore cannot treat one fingerprint as "Chrome"; it treats the pair (fingerprint, other claimed signals) as a device, and cross-checks. The same logic appears one layer up in Device Profile Consistency and in Cross-Validating Fingerprint Signals.
Reading a ClientHello Yourself
The parser below takes a hex ClientHello and names every field a WAF's collector would read: version, cipher suites, extension order, supported groups, ALPN, and where the GREASE values sit. It uses only struct and binascii. The two byte literals are a cold connection and the resumption that follows it, so you can see the difference the table describes.
import struct, binascii, textwrap
GREASE_VALUES = {0x0A0A, 0x1A1A, 0x2A2A, 0x3A3A, 0x4A4A, 0x5A5A, 0x6A6A,
0x7A7A, 0x8A8A, 0x9A9A, 0xAAAA, 0xBAAA, 0xCAAA, 0xDAAA,
0xEAAA, 0xFAAA}
CIPHERS = {0x1300: "TLS_AES_128_CCM_SHA256", 0x1301: "TLS_AES_128_GCM_SHA256",
0x1302: "TLS_AES_256_GCM_SHA384", 0x1303: "TLS_CHACHA20_POLY1305_SHA256",
0x009C: "TLS_RSA_WITH_AES_128_GCM_SHA256",
0x009D: "TLS_RSA_WITH_AES_256_GCM_SHA384",
0x002F: "TLS_RSA_WITH_AES_128_CBC_SHA", 0x0035: "TLS_RSA_WITH_AES_256_CBC_SHA",
0xC02B: "ECDHE_ECDSA_AES_128_GCM_SHA256", 0xC02F: "ECDHE_RSA_AES_128_GCM_SHA256",
0xC02C: "ECDHE_ECDSA_AES_256_GCM_SHA384", 0xC030: "ECDHE_RSA_AES_256_GCM_SHA384",
0xCCA9: "ECDHE_ECDSA_CHACHA20_POLY1305", 0xCCA8: "ECDHE_RSA_CHACHA20_POLY1305",
0xCC02: "ECDHE_ECDSA_AES_256_CCM", 0xCC01: "ECDHE_ECDSA_AES_128_CCM",
0xC024: "ECDHE_ECDSA_AES_128_CBC_SHA", 0xC028: "ECDHE_RSA_AES_256_CBC_SHA",
0xC023: "ECDHE_ECDSA_AES_128_CBC_SHA256", 0xC027: "ECDHE_RSA_AES_128_CBC_SHA256",
0xCCA2: "ECDHE_ECDSA_AES_128_CBC_SHA256"}
GROUPS = {0x0016: "secp256k1", 0x0017: "secp256r1", 0x0018: "secp384r1",
0x0019: "secp521r1", 0x001D: "x25519", 0x001E: "x448", 0x0100: "ffdhe2048"}
VERSIONS = {0x0301: "TLS 1.0", 0x0302: "TLS 1.1", 0x0303: "TLS 1.2", 0x0304: "TLS 1.3"}
EXTENSIONS = {0x0000: "server_name", 0x0005: "status_request", 0x000A: "supported_groups",
0x000B: "ec_point_formats", 0x000D: "signature_algorithms", 0x0010: "alpn",
0x0012: "signed_certificate_timestamp", 0x0015: "padding",
0x0017: "extended_master_secret", 0x001C: "record_size_limit",
0x0023: "session_ticket", 0x0027: "compress_certificate",
0x0029: "pre_shared_key", 0x002B: "supported_versions",
0x002D: "psk_key_exchange_modes", 0x0033: "key_share",
0xFF01: "renegotiation_info", 0xFE0D: "encrypted_client_hello"}
VECTOR_EXT = {"supported_groups": GROUPS, "supported_versions": VERSIONS}
def is_grease(value):
'''RFC 8701: a reserved value whose two bytes are equal and both in 0x0a..0xfa.'''
return (value >> 8) == (value & 0xFF) and 0x0A <= (value >> 8) <= 0xFA
def u16(buf, offset):
return struct.unpack_from("!H", buf, offset)[0]
def vector16(payload):
'''A 2-byte list length followed by 2-byte values; skip the length prefix.'''
return [u16(payload, i) for i in range(2, len(payload), 2)]
def alpn_names(payload):
'''ALPN body: a 2-byte list length, then one length-prefixed name per protocol.'''
out, offset = [], 2
while offset < len(payload):
size = payload[offset]
out.append(payload[offset + 1:offset + 1 + size].decode("ascii"))
offset += 1 + size
return out
def parse_client_hello(blob):
'''Pull the named fields out of one ClientHello record.'''
if blob[0] != 0x16 or blob[5] != 0x01:
raise ValueError("not a TLS 1.x ClientHello record")
body = blob[5:5 + 3 + u16(blob, 3)]
offset = 4
legacy_version = u16(body, offset)
offset += 2 + 32 # legacy_version, client random
offset += 1 + body[offset] # legacy_session_id
cipher_len = u16(body, offset)
ciphers = [u16(body, i) for i in range(offset + 2, offset + 2 + cipher_len, 2)]
offset += 2 + cipher_len
offset += 1 + body[offset] # compression methods
end = offset + 2 + u16(body, offset)
offset += 2
extensions = []
while offset < end:
etype = u16(body, offset)
elen = u16(body, offset + 2)
extensions.append((etype, body[offset + 4:offset + 4 + elen]))
offset += 4 + elen
return {"legacy_version": legacy_version, "ciphers": ciphers, "extensions": extensions}
def ext_name(etype):
return "GREASE" if is_grease(etype) else EXTENSIONS.get(etype, f"0x{etype:04x}")
def describe(blob, label):
'''Print the ClientHello the way a WAF's TLS collector would.'''
hello = parse_client_hello(blob)
kinds = [ext_name(t) for t, _ in hello["extensions"]]
print(f"=== {label}: {len(blob)} bytes on the wire ===")
print(f"legacy_version 0x{hello['legacy_version']:04x} "
f"({VERSIONS[hello['legacy_version']]} record layer)")
ciphers = ["GREASE" if is_grease(c) else CIPHERS.get(c, f"0x{c:04x}")
for c in hello["ciphers"]]
grease_at = [i for i, c in enumerate(hello["ciphers"]) if is_grease(c)]
print(f"ciphers {len(ciphers)} offered, GREASE at index {grease_at}")
print(textwrap.fill(", ".join(ciphers), width=72,
initial_indent=" ", subsequent_indent=" "))
print(f"extensions {len(kinds)} total")
print(textwrap.fill(", ".join(kinds), width=72,
initial_indent=" ", subsequent_indent=" "))
for (etype, data), kind in zip(hello["extensions"], kinds):
if kind == "alpn":
detail = ", ".join(alpn_names(data))
elif kind in VECTOR_EXT:
table = VECTOR_EXT[kind]
detail = " ".join("GREASE" if is_grease(v) else table.get(v, f"0x{v:04x}")
for v in vector16(data))
elif not data:
detail = "empty"
else:
detail = f"{len(data)} bytes"
print(f" ext {etype:#06x} {kind:30} {detail}")
names = {EXTENSIONS.get(t) for t, _ in hello["extensions"]}
print("verdict " + ("resumption, PSK offered" if "pre_shared_key" in names
else "full handshake, ECDHE key_share"))
print()
FULL_HELLO = binascii.unhexlify(
"160301012a010001260303000102030405060708090a0b0c0d0e0f1011121314"
"15161718191a1b1c1d1e1f104e5c1a9f77e04b2c8d3a6f10b9c25d8e002c0a0a"
"130113021303cc02cc01cca9cca8c02bc02fc02cc0301300cca2c024c028c023"
"c027009c009d002f0035010000c10a0a000000170000ff01000100000a000c00"
"0a0a0a001d001700180019000b00020100002300000010000e000c0268320868"
"7474702f312e31000500050100000000000d001400120a0a0403080404010503"
"080505010806060100120000001c00024001002b000800060a0a030403030033"
"00260024001d002004aeb3a8f5d0e0b5d2c98a2b41f6e7c30d5a9b8e7c6d5a4b"
"3c2d1e0f1a2b3c4d002d000302010200270004030203011a1a00000015001000"
"000000000000000000000000000000")
RESUMED_HELLO = binascii.unhexlify(
"1603010108010001040303000102030405060708090a0b0c0d0e0f1011121314"
"15161718191a1b1c1d1e1f104e5c1a9f77e04b2c8d3a6f10b9c25d8e002c0a0a"
"130113021303cc02cc01cca9cca8c02bc02fc02cc0301300cca2c024c028c023"
"c027009c009d002f00350100009f0a0a000000170000ff01000100000a000c00"
"0a0a0a001d001700180019000b000201000010000e000c02683208687474702f"
"312e31000500050100000000000d001400120a0a040308040401050308050501"
"0806060100120000001c00024001002b000800060a0a0304030300330000002d"
"000302010200270004030203011a1a000000290004000f424000150010000000"
"00000000000000000000000000")
describe(FULL_HELLO, "cold connection, Chrome-shaped")
describe(RESUMED_HELLO, "second connection, ticket reused")
=== cold connection, Chrome-shaped: 303 bytes on the wire ===
legacy_version 0x0303 (TLS 1.2 record layer)
ciphers 22 offered, GREASE at index [0]
GREASE, TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384,
TLS_CHACHA20_POLY1305_SHA256, ECDHE_ECDSA_AES_256_CCM,
ECDHE_ECDSA_AES_128_CCM, ECDHE_ECDSA_CHACHA20_POLY1305,
ECDHE_RSA_CHACHA20_POLY1305, ECDHE_ECDSA_AES_128_GCM_SHA256,
ECDHE_RSA_AES_128_GCM_SHA256, ECDHE_ECDSA_AES_256_GCM_SHA384,
ECDHE_RSA_AES_256_GCM_SHA384, TLS_AES_128_CCM_SHA256,
ECDHE_ECDSA_AES_128_CBC_SHA256, ECDHE_ECDSA_AES_128_CBC_SHA,
ECDHE_RSA_AES_256_CBC_SHA, ECDHE_ECDSA_AES_128_CBC_SHA256,
ECDHE_RSA_AES_128_CBC_SHA256, TLS_RSA_WITH_AES_128_GCM_SHA256,
TLS_RSA_WITH_AES_256_GCM_SHA384, TLS_RSA_WITH_AES_128_CBC_SHA,
TLS_RSA_WITH_AES_256_CBC_SHA
extensions 17 total
GREASE, extended_master_secret, renegotiation_info, supported_groups,
ec_point_formats, session_ticket, alpn, status_request,
signature_algorithms, signed_certificate_timestamp, record_size_limit,
supported_versions, key_share, psk_key_exchange_modes,
compress_certificate, GREASE, padding
ext 0x0a0a GREASE empty
ext 0x0017 extended_master_secret empty
ext 0xff01 renegotiation_info 1 bytes
ext 0x000a supported_groups GREASE x25519 secp256r1 secp384r1 secp521r1
ext 0x000b ec_point_formats 2 bytes
ext 0x0023 session_ticket empty
ext 0x0010 alpn h2, http/1.1
ext 0x0005 status_request 5 bytes
ext 0x000d signature_algorithms 20 bytes
ext 0x0012 signed_certificate_timestamp empty
ext 0x001c record_size_limit 2 bytes
ext 0x002b supported_versions GREASE TLS 1.3 TLS 1.2
ext 0x0033 key_share 38 bytes
ext 0x002d psk_key_exchange_modes 3 bytes
ext 0x0027 compress_certificate 4 bytes
ext 0x1a1a GREASE empty
ext 0x0015 padding 16 bytes
verdict full handshake, ECDHE key_share
=== second connection, ticket reused: 269 bytes on the wire ===
legacy_version 0x0303 (TLS 1.2 record layer)
ciphers 22 offered, GREASE at index [0]
GREASE, TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384,
TLS_CHACHA20_POLY1305_SHA256, ECDHE_ECDSA_AES_256_CCM,
ECDHE_ECDSA_AES_128_CCM, ECDHE_ECDSA_CHACHA20_POLY1305,
ECDHE_RSA_CHACHA20_POLY1305, ECDHE_ECDSA_AES_128_GCM_SHA256,
ECDHE_RSA_AES_128_GCM_SHA256, ECDHE_ECDSA_AES_256_GCM_SHA384,
ECDHE_RSA_AES_256_GCM_SHA384, TLS_AES_128_CCM_SHA256,
ECDHE_ECDSA_AES_128_CBC_SHA256, ECDHE_ECDSA_AES_128_CBC_SHA,
ECDHE_RSA_AES_256_CBC_SHA, ECDHE_ECDSA_AES_128_CBC_SHA256,
ECDHE_RSA_AES_128_CBC_SHA256, TLS_RSA_WITH_AES_128_GCM_SHA256,
TLS_RSA_WITH_AES_256_GCM_SHA384, TLS_RSA_WITH_AES_128_CBC_SHA,
TLS_RSA_WITH_AES_256_CBC_SHA
extensions 17 total
GREASE, extended_master_secret, renegotiation_info, supported_groups,
ec_point_formats, alpn, status_request, signature_algorithms,
signed_certificate_timestamp, record_size_limit, supported_versions,
key_share, psk_key_exchange_modes, compress_certificate, GREASE,
pre_shared_key, padding
ext 0x0a0a GREASE empty
ext 0x0017 extended_master_secret empty
ext 0xff01 renegotiation_info 1 bytes
ext 0x000a supported_groups GREASE x25519 secp256r1 secp384r1 secp521r1
ext 0x000b ec_point_formats 2 bytes
ext 0x0010 alpn h2, http/1.1
ext 0x0005 status_request 5 bytes
ext 0x000d signature_algorithms 20 bytes
ext 0x0012 signed_certificate_timestamp empty
ext 0x001c record_size_limit 2 bytes
ext 0x002b supported_versions GREASE TLS 1.3 TLS 1.2
ext 0x0033 key_share empty
ext 0x002d psk_key_exchange_modes 3 bytes
ext 0x0027 compress_certificate 4 bytes
ext 0x1a1a GREASE empty
ext 0x0029 pre_shared_key 4 bytes
ext 0x0015 padding 16 bytes
verdict resumption, PSK offered
Reading the output:
legacy_versionis0x0303(TLS 1.2) on both, because that field is frozen for compatibility. The real version lives in thesupported_versionsextension, which is why the parser prints that one separately.- The cold hello carries
session_ticket(empty, the client asking to be issued one) and akey_sharewith a 32-byte x25519 public key. The resumed hello dropssession_ticket, emptieskey_share, and gainspre_shared_keywith a 4-byte obfuscated ticket age. The parser's last line reads that as the verdict. pre_shared_keymust be the last extension beforepaddingin a real ClientHello, or the server rejects it. Both literals here satisfy that.- GREASE shows up as
0x0a0afirst in the cipher list, as the first entry ofsupported_groupsandsupported_versions, and as a whole empty extension at two positions. Four placements, one pattern, and matching only the hashed parts of it is not enough.
To capture your own, openssl s_client prints the same fields in prose form, and Wireshark dissects the handshake for you:
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>&1 \
| grep -A3 -E 'Cipher|Server Temp Key|Protocol|ALPN'
A Checklist for the Resumption Half
- Reuse connections. A pooled session or a warm browser profile. One handshake per request is the loudest signal on this page and it is not fixable in the request layer.
- Keep the ticket cache. A browser restarted per job resumption-free. A pool that survives a crawl batch is the cheapest fix available.
- Offer
h2first, thenhttp/1.1. And actually use what you offer. - Match the GREASE pattern, not just the hashed lists. Four placements, correct indices, correct count.
- Never trip a second HelloRetryRequest. If your client can be made to, that is worse than the JA3 mismatch you were fixing.
- Pair the stack with the platform. BoringSSL fingerprints on a claimed macOS Safari, or a Schannel-short cipher list on a claimed Linux Chromium, are contradictions.
- Check ticket lifetime against profile lifetime. A ticket older than the client-side cap is used for a full handshake no matter what the server offers.
The Legitimate Route
Confirming that your own client presents the ClientHello it claims to is diagnostic work, and the parser above is exactly the tool for it: run it against a handshake you initiated, and check the output against the browser version in your User-Agent. Turning the same knowledge into a forged client that impersonates someone else's browser or gets you past an access control is a different act, and the same applies to resumption: replaying someone else's session ticket is credential theft, not fingerprint work. Keep to systems you are authorised to test, or to the site you own.
Cross-links: TLS & JA3 Spoofing, The TLS Handshake, QUIC, HTTP/3 and Transport Fingerprints, HTTP/2 Frames & Header Perfecting, Signals and TLS config, Fingerprint Cross-Validation, Browser Pool Architecture.