The TLS session resumption fingerprint: tickets, PSK, and 0-RTT signals
Most TLS fingerprinting work stops at the first handshake. You capture the ClientHello, hash the cipher list and the extension list, and you have a JA3 or JA4 string that says something about who is calling. That model assumes every connection is a stranger meeting the server for the first time. Real clients are not strangers. A browser that loaded a page two minutes ago carries a session ticket, and on its next connection it offers that ticket back, sends a pre-shared key, and sometimes ships application data before the server has said a word. The handshake on the wire looks different because the relationship is different.
That second handshake is where the resumption layer lives, and it leaks. The presence of a ticket, the way it is offered, the obfuscated age stamped on it, whether the client dares to send 0-RTT early data, how long it remembers a ticket before discarding it: each of these is a signal that a passive observer can read and a spoofing client can get wrong. This post stays inside that layer. Not the ClientHello cipher order, not the HTTP/2 frames, just the machinery of remembering a previous connection and what that memory reveals.
We start with how resumption actually works across TLS 1.2 and 1.3, because the two versions resume differently and the difference matters for fingerprinting. Then the field layout of session tickets and the TLS 1.3 PSK extension, the part that ends up on the wire. Then 0-RTT early data, the most aggressive and most fragile resumption signal. Then the linkability problem that turned resumption into a tracking primitive, with the numbers from the research that named it. And finally what all of this does to JA3 and JA4, why the pre-shared key extension quietly broke the “one client, one fingerprint” assumption, and how a resumption spoof falls apart.
Two ways to resume, and why the seam shows
Before TLS 1.3, a server had two ways to let a client skip the expensive part of a handshake. The older one is the session ID: the server picks an opaque identifier, both sides cache the negotiated secret against it, and on the next connection the client echoes the ID in its ClientHello. The server looks it up, finds the secret still in memory, and resumes. This is stateful. The server has to remember every session it might want to resume, which does not scale across a fleet of machines that do not share memory.
RFC 5077, published in January 2008 by authors at Cisco and Nokia, fixed the scaling problem by moving the state to the client. Instead of caching the session, the server serializes it (the cipher suite, the master secret, the parameters), encrypts the blob under a key only the server knows, and hands that ciphertext to the client as a ticket. The client stores it and presents it back in a SessionTicket extension on the next ClientHello. The server decrypts, validates, and resumes without having kept a single byte of per-client state. The encryption key is the session ticket encryption key, the STEK, and in practice it is rotated on a schedule somewhere between one hour and a day.
TLS 1.3 collapsed all of this. There are no more separate session-ID and session-ticket resumption paths and no more PSK cipher suites. RFC 8446 replaced them with a single pre-shared key mechanism. After the first handshake completes, the server may send one or more NewSessionTicket messages, each of which establishes a distinct PSK derived from the resumption master secret. On a later connection the client offers one of those PSKs in the pre_shared_key extension, proves it holds the key with a binder, and the two sides resume from the shared secret. A ticket in TLS 1.3 is not a cached-session pointer the way a 1.2 ticket was; it is a key identifier, and each one is single-purpose.
*The three resumption paths a server might offer. The first request from any client carries none of them; the signal begins on the second.*The seam between these mechanisms is the first fingerprint. A client that resumes with a session ID is speaking 1.2 and is not a modern browser doing anything current. A client that offers a 1.2 ticket but never a 1.3 PSK has either disabled 1.3 or is a library pretending to be a browser without implementing the 1.3 resumption path. The shape of the resumption attempt narrows the client before anyone looks at the secret inside it.
What the ticket and the PSK actually carry
The NewSessionTicket message in TLS 1.3 is small and its fields are named in the RFC. There is ticket_lifetime, a 32-bit count of seconds the ticket is valid, capped by the spec at 604800 seconds, which is seven days. There is ticket_age_add, a 32-bit random value the server picks per ticket. There is ticket_nonce, used along with the resumption master secret to derive that ticket’s PSK. There is the ticket itself, the opaque identifier the client will send back. And there is an extensions block, which in the base spec carries at most the early_data extension to signal that 0-RTT is permitted on this ticket.
When the client comes back, it puts the ticket into the pre_shared_key extension. The structure there has two halves. The first is a list of PskIdentity entries, each holding the identity (the ticket bytes) and an obfuscated_ticket_age. The second is a list of binders, one PskBinderEntry per identity, each a keyed MAC over the handshake transcript that proves the client actually holds the PSK rather than just replaying a captured ticket. The binder is what stops a passive eavesdropper from stealing a ticket off the wire and resuming with it.
The obfuscated age is the interesting field. The client knows how long ago it received the ticket. It does not send that number in the clear. It computes obfuscated_ticket_age = ticket_age + ticket_age_add mod 2^32, where ticket_age is the milliseconds elapsed since the NewSessionTicket arrived and ticket_age_add is the random offset the server stamped onto that specific ticket. Only the server knows the offset, so only the server can subtract it back out and recover the true age. The point is anti-replay: the server checks the recovered age against a freshness window and rejects a ticket that arrives too far outside the time it should plausibly have taken to come back.
There is a hard structural rule attached to all this: pre_shared_key MUST be the last extension in the ClientHello. The reason is the binder. The MAC has to cover the entire ClientHello up to but not including the binders themselves, so the extension that holds the binders has to sit at the very end where nothing follows it. This constraint matters later, because it is exactly the property that makes naive extension-list fingerprinting trip over resumption.
What none of this carries, and this is worth saying plainly, is any public structure inside the ticket bytes. The ticket is a server-chosen opaque blob. Its internal layout is not defined by the RFC and varies per implementation; OpenSSL, BoringSSL, and a hardware stack all format their encrypted ticket differently. So when an observer reasons about a ticket, they reason about its length, its lifetime hint, its age, and whether it was offered at all. The plaintext inside is the server’s business and not visible on the wire.
The ticket length itself is more telling than it first appears. A short fixed-size ticket suggests the server is using a database-key-style identifier and keeping the real state server-side, the way a session-ID resumption would, while a long ticket of a few hundred bytes suggests the full encrypted session blob is riding on the client. Stacks differ enough here that ticket length, lifetime hint, and how many NewSessionTicket messages the server sends after a handshake together form a coarse server-side fingerprint of the TLS implementation behind the endpoint. A server that ships two tickets immediately after the handshake is behaving like one stack; a server that drips a single ticket later is behaving like another. None of that identifies the client, but it constrains what the client should expect to see, and a client whose resumption behavior does not match the server’s issuance pattern is doing something the genuine pairing would not.
The encryption that wraps the ticket is the other side of this. A 1.2-style ticket and the PSK material behind a 1.3 ticket are both protected, in the stateless case, under a server-held key, and the lifetime of that key sets a ceiling on forward secrecy: anyone who later recovers the session ticket encryption key can unwrap tickets minted under it and reach the secrets inside. That is why the operational advice is to rotate the STEK on a tight schedule, generate it randomly, distribute it across a fleet rather than baking it in, and keep it in memory rather than on disk. Rotation cadence is invisible to a passive client, but it interacts with the lifetime hint: a ticket whose advertised lifetime outruns the key that protects it is a ticket the server will reject early, and a client that keeps presenting such a ticket past the rotation boundary will see resumption silently fall back to a full handshake. The fallback is observable, and a client that does not handle it the way the real browser does is, again, visible.
0-RTT early data, the loudest signal
The most aggressive thing a resuming client can do in TLS 1.3 is send 0-RTT early data. If the server marked a ticket with the early_data extension when it issued it, the client may, on resumption, encrypt application data under a key derived from the offered PSK and ship it in the very first flight, alongside the ClientHello, before the server has responded. The server signals its willingness to accept early data through the early_data extension, type 42, and bounds it with a max_early_data_size field that says how many bytes it will take before the handshake finishes.
This is fast and it is dangerous, and the RFC is unusually blunt about why. Early data is not forward secret, because it is encrypted solely under keys derived from the offered PSK rather than from a fresh key exchange. And it carries no anti-replay guarantee between connections. Ordinary 1-RTT data is bound to the server’s fresh Random value, so a captured handshake cannot be replayed into a new session. Early data does not depend on the ServerHello at all, which is what makes it cheap, and also what makes a captured 0-RTT flight replayable. An attacker who records the early-data packets can resend them, and absent server-side defenses the server may process the request twice.
*0-RTT folds the request into the opening packet. The round trip it saves is the same round trip that would have bound the request to a fresh server nonce.*Servers defend against this in known ways. They can refuse early data and force the client to resend after the handshake. They can return HTTP 425 Too Early to push a single request back. They can restrict 0-RTT to idempotent methods, which in practice means GET. And the obfuscated age plus a narrow freshness window lets a server reject a ticket whose recovered age does not match the wall-clock arrival time, which catches the dumbest replays. The RFC’s floor is that any single server instance accepts a given 0-RTT handshake at most once, which bounds replays to the number of instances in the fleet rather than to infinity. That is a floor, not a ceiling, and operators are told to do better where they can.
For fingerprinting the value is simpler than the security analysis. Whether a client sends 0-RTT at all is a strong behavioral tell. Most HTTP libraries do not implement early data; many that support TLS 1.3 resumption still never send 0-RTT because the application layer above them never opted in. Browsers are selective about it. A client that confidently ships early data on resumption is in a small population, and a client that claims to be a browser but never does so when the real browser would, or does so when the real one would not, has a mismatch sitting in the open. The detail of whether early data is attempted, and on which request methods, is the kind of second-order tell that survives a good ClientHello spoof. We have written separately about detecting curl-impersonate and uTLS through their second-order behavior; 0-RTT belongs on that list.
Resumption as a tracking primitive
The sharpest work on the privacy cost of resumption came out of the University of Hamburg in 2018. Erik Sy, Christian Burkert, Hannes Federrath, and Mathias Fischer measured how session resumption lets a server re-identify a returning client without any cookie, and the numbers are worth carrying around.
The mechanism is straightforward once you see it. A session ticket is a unique token the server gave to one client. When that client returns and presents the ticket, the server knows it is the same client, by construction, because nobody else holds that ticket. That is the entire point of resumption. The privacy problem is that the same property that lets the server resume the session also lets it link two visits to the same user, and it does so for as long as the ticket lives and as long as the client keeps presenting it. The lifetime hint the server sends controls how long the window stays open. Rotate the ticket on every connection and chain the lifetimes, and the window never closes as long as the user keeps coming back inside it.
The measurements put scale on this. Across the TLS-capable sites among the Alexa top million, the study found that roughly 80 percent of the session-ticket-enabled sites used a configuration that allowed tracking, and that under TLS 1.3’s seven-day maximum lifetime, about 65 percent of users in their dataset could be tracked permanently given normal browsing frequency. Browser defaults at the time capped the resumption lifetime far lower, with around two thirds of the browsers examined holding session state to about an hour, but that hour resets on every visit. The paper’s headline figure is that with standard browser settings, the average user could be tracked for up to eight days through resumption alone. They also noted that some large sites set conspicuously long lifetime hints: Facebook around 48 hours, Google around 28 hours, against a web where most sites sat far lower.
*Lifetime hints from the Sy et al. measurement, against the RFC 8446 ceiling. The bar is the upper bound on how long a single ticket can re-link a returning client.*The mitigations that followed are the reason browsers do not ship long-lived resumption by default. Limiting ticket lifetime, issuing tickets that are single-use so re-presentation does not chain, partitioning the resumption cache by first-party site so a tracker embedded across many sites cannot use one ticket to follow you between them: these are the moves browsers made. The patent literature reflects the same anxiety. NXP holds US 11,412,373, “Client privacy preserving session resumption,” granted in 2022, which describes mixing a changing salt into the resumption response so that a static ticket identifier cannot be used to correlate a device across its repeated connections. The threat it names is exactly the Hamburg threat, a static token that follows a client around, applied to constrained wireless devices.
Site partitioning of the resumption cache is the same idea that drives cache and connection partitioning generally, and it interacts with proxy and session management in ways worth thinking through if you run a fleet; we go deeper on the operational side in our writeup on session and cookie management across a proxy fleet.
What this does to JA3 and JA4
JA3 hashes the ClientHello: version, cipher list, extension list, elliptic curves, and point formats, concatenated and MD5’d. It does not normalize the extension order, and it does not exclude any extension. That design has a problem with resumption, because the pre_shared_key extension appears only on a return visit and never on a cold connection. So the same browser produces one JA3 on its first request and a different JA3 on its second, purely because the second ClientHello carries an extra extension that the first did not. The resumption layer breaks JA3’s core promise that one client maps to one hash. (Extension order also breaks JA3 for a separate reason, Chrome’s deliberate randomization, which we cover in the extension randomization post.)
JA4 was built to be sturdier. It sorts the cipher and extension lists into hex order before hashing, so order no longer perturbs the fingerprint, and it deliberately ignores two extensions: SNI, 0x0000, and ALPN, 0x0010, because those values are captured elsewhere in the JA4 string and would otherwise make the same client hash differently per destination. But JA4 as originally specified did not exclude pre_shared_key, 0x0029. So the resumption problem followed JA4 across: a browser using PSK resumption, Safari is the often-cited example, produces one JA4 on the first request and a different JA4 on the resumed one, because the offered PSK adds an extension to the sorted list. The fingerprint that was supposed to be stable across a client’s requests is not stable across that client’s own resumption.
*The resumed ClientHello carries one extra extension. Hash the sorted list naively and you get two fingerprints for the same browser.*The fix the tooling settled on is to treat resumption-related extensions as ephemeral and drop them before computing the fingerprint. ntop’s nDPI added exactly this: an option to exclude ephemeral TLS extensions from the JA4 calculation, where the ephemeral set is the session ticket extension, pre_shared_key, and padding. With the option on, a client’s JA4 stays constant whether or not it is resuming. The fact that this had to be bolted on, with a config flag and a default of off, tells you how often the resumption layer quietly corrupts a fingerprint that everyone assumed was stable. If you are matching JA4 strings and seeing a browser flip between two hashes, the PSK extension on resumed connections is the first thing to check. The full anatomy of the JA4 string and what each segment captures is in our JA4+ suite breakdown, and the extension-by-extension view of the ClientHello is in the field reference.
There is a second, sharper way the resumption layer betrays a spoof. A client that mimics a browser’s ClientHello byte for byte, the cipher order, the extension order, the GREASE values, can still get the resumption behavior wrong. Real browsers maintain a resumption cache with specific eviction and lifetime behavior, send PSKs derived correctly from prior NewSessionTicket messages, compute valid binders, stamp plausible obfuscated ages, and decide about 0-RTT the way the real client decides. A spoofing client that never resumes, or resumes with a malformed binder, or offers a ticket whose obfuscated age is wildly inconsistent with the connection timing, or never sends early data where the genuine browser would, presents a ClientHello that passes and a resumption story that does not. The first handshake can be forged in isolation. The relationship between the first handshake and the second is harder to forge, because it requires a stateful client that remembers correctly. Tooling like uTLS reproduces the ClientHello faithfully, but reproducing the full resumption lifecycle across connections is a different and larger problem.
The cross-version behavior adds another seam. A browser negotiates 1.3 with a modern server and resumes with a PSK; against an older endpoint it falls back to 1.2 and resumes with a ticket. A real client moves between these modes coherently, offering 1.3 resumption where 1.3 was negotiated and 1.2 resumption where it was not, and it does so per destination based on what each server supported on the prior connection. A spoofing client that always offers a 1.3 PSK, or never does, or offers a 1.2 ticket to a server it just completed a 1.3 handshake with, has a resumption story that contradicts its own handshake history. The mismatch is not in any single packet; it is in the relationship between this connection and the last one to the same server, which is precisely the state a stateless client does not keep.
Then there is timing, which the obfuscated age makes legible to the server in a way it is not to a passive observer. The server recovers the true ticket age by subtracting its own ticket_age_add, and it can compare that recovered age against the actual wall-clock gap between issuing the ticket and seeing it return. A genuine browser’s recovered age tracks real elapsed time closely, because the browser stamps the age from the same clock the whole way through. A client that replays a captured handshake, or that reconstructs a ticket offer without faithfully tracking when the ticket was issued, produces a recovered age that does not line up with arrival time. That inconsistency is a cheap server-side check and one of the harder things for a forging client to get right, because it requires not just the right bytes but the right clock behavior across the gap between two connections. Resumption, in other words, smuggles a timing assertion into every return visit, and a client that lies about its history tends to lie about its clock too.
The resumption layer remembers, and that is the whole problem
Session resumption exists to make the second connection cheaper than the first, and every mechanism that makes it cheaper also makes the second connection more identifiable. A ticket is a token the server minted for exactly one client; presenting it back is, by design, a claim of continuity, and continuity is identity. The obfuscated age is an anti-replay clock that also constrains how a faithful client must behave over time. The 0-RTT flag is a latency optimization that also sorts clients into the small set willing to ship data before the handshake completes and the large set that never does. None of these were built as fingerprints. All of them work as one.
The practical reading for anyone who fingerprints, or who tries to look like something they are not, is that the resumption layer is stateful where the ClientHello is static. A static artifact can be copied perfectly. A stateful relationship has to be lived, connection after connection, with a cache that evicts correctly and tickets that are offered with the right age at the right time. That is the part the field is still building tooling around, which is why a fingerprinting library in 2026 still ships a flag to ignore the pre_shared_key extension rather than a settled answer for what to do with it. The handshake you see first tells you what the client claims to be. The way it resumes tells you whether it was telling the truth.
Sources & further reading
- Salowey, J., Zhou, H., Eronen, P., Tschofenig, H. (2008), RFC 5077: Transport Layer Security (TLS) Session Resumption without Server-Side State — defines stateless ticket-based resumption and the session ticket encryption key.
- Rescorla, E. (2018), RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3 — the PSK resumption mechanism, NewSessionTicket fields, the pre_shared_key and early_data extensions, and the Section 8 anti-replay rules.
- Sy, E., Burkert, C., Federrath, H., Fischer, M. (2018), Tracking Users across the Web via TLS Session Resumption — ACSAC paper measuring resumption as a tracking vector, with the lifetime and tracking-duration figures.
- The Register (2018), How a quirk of TLS can smash someone’s web privacy — coverage of the Hamburg study with the 80 percent, 65 percent, and eight-day figures and the Facebook/Google lifetime hints.
- Cloudflare (2018), A Detailed Look at RFC 8446 (a.k.a. TLS 1.3) — walkthrough of PSK resumption, 0-RTT early data, and the replay tradeoff.
- incolumitas (2022), Fingerprinting TLS: Core differences between TLS 1.2 and TLS 1.3 — how the two versions resume differently and what that means for fingerprint stability.
- keymaterial.net (2023), Stupid TLS Facts: TLS Resumption — session IDs versus tickets versus PSK, STEK rotation, and the forward-secrecy implications of long-lived ticket keys.
- FoxIO (2023), JA4: TLS Client Fingerprinting — the JA4 algorithm, the sorted extension list, and the explicit exclusion of SNI and ALPN.
- ntop (2024), Is JA4 Now Obsolete? — the pre_shared_key problem on resumed connections and the nDPI ephemeral-extension option covering session ticket, pre_shared_key, and padding.
- Scrapfly, pre_shared_key TLS extension explained — extension type 41 and its role in JA3-style fingerprinting.
- Medwed, M., Lemsitzer, S. / NXP (2022), US Patent 11,412,373: Client privacy preserving session resumption — salt-based defense against correlating a device across repeated resumptions.
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