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_version is 0x0303 (TLS 1.2) on both, because that field is frozen for compatibility. The real version lives in the supported_versions extension, which is why the parser prints that one separately.
  • The cold hello carries session_ticket (empty, the client asking to be issued one) and a key_share with a 32-byte x25519 public key. The resumed hello drops session_ticket, empties key_share, and gains pre_shared_key with a 4-byte obfuscated ticket age. The parser's last line reads that as the verdict.
  • pre_shared_key must be the last extension before padding in a real ClientHello, or the server rejects it. Both literals here satisfy that.
  • GREASE shows up as 0x0a0a first in the cipher list, as the first entry of supported_groups and supported_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 h2 first, then http/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.