Skip to content

The history of TLS fingerprinting: from p0f to JA3 to JA4+

· 21 min read
Copyright: MIT
TLS fingerprinting wordmark with a sample JA4 string and an orange underline on a dark background

A TLS ClientHello is the first thing your client says to a server, and it is sent in the clear. Before any key is agreed, before any certificate is checked, the client announces a precise list: which protocol versions it will accept, which cipher suites it prefers and in what order, which extensions it carries, which elliptic curves it knows, which signature algorithms it will verify. None of this is secret. All of it is chosen by the TLS library that built the message, not by the human driving the browser. So two questions follow naturally. How much does that opening packet reveal about the software that sent it? And once you can name the software from the packet, what can you do with that?

The discipline that answers those questions has a clean lineage. It runs from passive OS-fingerprinting tools that learned to read SSL handshakes, through a 2015 conference talk that made client identification practical, through a Salesforce standard that everyone adopted and a Chrome change that broke it, to the format that replaced it. This post walks that line in order: the p0f-era SSL patches, Lee Brotherston’s FingerprinTLS, Salesforce’s JA3 and the JA3S server variant, the academic and Cisco work that added destination context, JARM’s active probing, the extension-randomization break of 2023, and FoxIO’s JA4+ suite. The cipher-sorting and string-format mechanics of the JA3-to-JA4 transition have their own post; here the lane is the history of the field itself.

2009-2012: reading the handshake before anyone called it fingerprinting

The idea that an unencrypted ClientHello identifies its sender predates any of the named standards. p0f, Michal Zalewski’s passive fingerprinting tool, had been reading TCP/IP stacks since 2000, inferring the operating system from SYN-packet quirks like window size, TTL, and option ordering. That work has its own history. The version 3 rewrite in 2012 added application-layer modules, but mainline p0f only ever shipped an HTTP module for the application layer; the README of the day lists SMTP, FTP, and SSL as wanted but unwritten.

The SSL piece came from outside the core tree. In June 2012 Marek Majkowski published a patch against p0f 3.05b that taught the tool to decode SSLv2 and SSLv3/TLS ClientHello packets and emit a signature from them. His write-up put the reasoning plainly: the cipher and extension lists “differ between clients and often it is possible to identify an SSL client by looking at them,” which meant you could tell Firefox, Chrome, Opera, and Internet Explorer apart “by just looking at the initial HTTPS packet, which is unencrypted.” The signature he proposed was four colon-separated fields, version followed by the ordered cipher list followed by the ordered extension list followed by a flags field. His own Chrome 19 produced something shaped like 3.1:c00a,c014,88,...:?0,ff01,a,b,23,3374:compr.

That layout is worth pausing on, because the field choices in it survived more or less intact through every later standard. Version, ciphers in order, extensions in order. The accompanying ssl-notes in the fork already worried about the cases that would dog fingerprinting for the next decade: the SNI extension is optional and its presence varies by how the client was invoked, so it got marked with an optional sign rather than treated as a hard signal. The 2012 patch never merged into mainline p0f. It did not need to. It had already shown the shape of the answer.

A 2016 academic paper in the EURASIP Journal on Information Security pushed the same idea into rigor, building a dictionary that mapped User-Agent strings to the SSL/TLS fingerprints they co-occurred with, so that an observer seeing only the encrypted side of a connection could still guess the client. The mechanism was the same as Majkowski’s. The contribution was scale and a method for keeping the dictionary current.

The ClientHello fields a fingerprint reads (all sent in cleartext) record header · client random · session id ignored version cipher suites (ordered) extensions (ordered) supported groups EC point formats sig algs / ALPN (later schemes) The library that built the message chose every orange field. The user chose none of them. *The fields in orange are the ones every fingerprinting scheme from 2012 onward reads. The grey fields carry per-connection randomness and are discarded.*

2015: FingerprinTLS makes it a discipline

The packet decoding existed. What did not yet exist was a tool built specifically to turn handshakes into a usable, distributable database for defenders, plus a clear argument for why it would keep working. Lee Brotherston supplied both at DerbyCon in 2015. His talk, given while he was at Leviathan Security Group, presented FingerprinTLS, a libpcap-based tool that watched live traffic or replayed pcaps and matched ClientHellos against a fingerprint database it shipped with.

The argument behind it is the part that aged best. Crypto configuration changes slowly. A browser ships a new version every few weeks, but the cipher list and extension set it advertises stay the same across long stretches of releases, because nobody re-tunes the TLS stack on every minor bump. Malware changes its handshake even less often. Brotherston’s observation, as he put it at the time, was that even the big browsers “have the same crypto between versions,” and the malware he looked at “never really changed their crypto signatures.” A signal that is both high-entropy and stable is exactly what a fingerprint wants. User-Agent strings are trivially editable; the TLS stack is baked into the binary.

FingerprinTLS framed the use cases that the whole field would inherit. A blue team could detect software on the network that policy did not allow, by recognizing its handshake. A defender could write an IPS rule keyed on a fingerprint rather than on an IP or a mutable header. The tool emitted JSON for fingerprints it generated and carried a binary database for matching. It compiled on macOS, FreeBSD, and Linux. None of this was academically novel after the 2012 p0f work, but it was the first packaging aimed squarely at operational defenders, and it set the template: capture the handshake, reduce it to a compact signature, share the signature, alert on matches.

The threat-model assumption underneath all of it deserves saying out loud, because it is what makes TLS fingerprinting work at all and also what bounds it. A fingerprint is only useful if it is stable for the thing you want to catch and varied across the things you want to tell apart. The TLS stack satisfies both because the people writing it optimize for interoperability, not for blending in. A browser vendor picks a cipher order once, ships it to a billion installs, and leaves it alone for a year, which makes the fingerprint stable. A different vendor, or a malware author linking a different TLS library, makes different choices, which makes the fingerprints varied. The signal is a byproduct of engineering decisions nobody made with fingerprinting in mind. That is why it was reliable for years and also why, the moment a vendor decides to fight it, the signal degrades on the vendor’s schedule rather than the defender’s.

The gap between FingerprinTLS and what came next was not technical. It was distribution. Brotherston’s signature format was his own, and adoption stayed within the circle of people who ran his tool. The field needed a representation simple enough that everyone would compute the same value for the same handshake, and small enough to drop into a log line. That arrived two years later.

2017: JA3 and the value of a single hash

Salesforce open-sourced JA3 in 2017, the work of John Althouse, Jeff Atkinson, and Josh Atkins. The initials are theirs. What JA3 added over FingerprinTLS was not a better feature set; it was a canonical, lossy reduction that fit in a log field. JA3 reads five things from the ClientHello in a fixed order, the TLS version, the cipher suites, the extension list, the supported elliptic curves, and the elliptic-curve point formats, joins each field’s values with hyphens, joins the five fields with commas, and MD5-hashes the result into 32 hex characters.

So a decimal string like 769,47-53-5-10-49161-49162-49171-49172-50-56-19-4,0-10-11,23-24-25,0 collapses to ada70206e40642a3e4461f35503241d5. That hash is the whole point. It is short, it is the same on every platform that implements the spec, and it slots into a SIEM column, a threat-intel feed, or a firewall rule without anyone needing to agree on a richer schema first. JA3 made the per-decimal string canonical and the hash shareable, and that combination is what drove adoption.

JA3: five ordered fields, comma-joined, then MD5 version ciphers extensions curves pt formats 769,47-53-5-10-49161-49162-...,0-10-11,23-24-25,0 MD5 ada70206e40642a3e4461f35503241d5 The hash is lossy and order-sensitive. Reorder the extensions and the hash changes completely. GREASE values are stripped before hashing so they do not add noise. *JA3 reduces a ClientHello to 32 hex characters. The order-sensitivity in the highlighted note is the seam that would later split open.*

JA3 shipped with one piece of foresight that mattered. It stripped GREASE values before hashing. GREASE, defined in RFC 8701, has clients inject reserved junk values into cipher and extension lists specifically to keep servers from ossifying on a fixed set; without stripping them, a GREASE-using client’s fingerprint would wobble. The dedicated GREASE post covers the mechanics. JA3 also defined a server-side companion, JA3S, which hashes the version, the single chosen cipher, and the extensions from the ServerHello, giving a string like 769,47,65281-0-11-35-5-16. Pairing a client JA3 with the JA3S it elicited is more discriminating than either alone, because it captures how a particular server responds to a particular client.

Adoption was the story of the next three years. JA3 landed in Suricata, Zeek, MISP, Moloch/Arkime, Splunk, Elastic’s Packetbeat, Darktrace, and a long list of commercial sensors, and eventually into the big cloud WAFs. VirusTotal began surfacing JA3 hashes on samples. The reason it spread is mundane and important: it was a 32-character string anyone could compute and nobody had to license. Threat intel feeds could publish “this malware family uses JA3 hash X” and every consumer could match on it without a shared parser. That is the leverage a canonical hash buys, and it is why JA3 became the default vocabulary for talking about TLS clients for half a decade.

2019-2020: destination context, and the limits of the hash

The cracks in the single-hash model showed up early, and the people who found them were measuring at scale. A pair of papers out of Cisco, whose authors built the Joy and later Mercury capture tools, made the core point with data: a TLS fingerprint is not unique to one application. Many distinct programs, often sharing the same underlying TLS library, emit identical ClientHellos. A bare JA3-style hash collides across all of them.

Their fix was to stop treating the fingerprint as a standalone identifier and start conditioning it on context. The destination matters: the same client fingerprint going to a Microsoft update endpoint and going to a freshly registered domain in a hosting range carry very different prior probabilities of being a given process. Cisco’s Mercury, which produces fingerprints for TLS, DTLS, SSH, HTTP, TCP, and other protocols, pairs the handshake fingerprint with destination features and a knowledge base of observed fingerprint-to-process mappings to do process identification rather than mere library identification. The 2019 work, presented at the Internet Measurement Conference, and the 2020 follow-up on destination context and knowledge bases, reframed the question from “what is this fingerprint” to “what is the probability distribution over processes given this fingerprint and where it is going.”

This is the quiet, durable lesson of the field, and it keeps getting relearned. The fingerprint is a feature, not a verdict. On its own a TLS hash buckets traffic into library-shaped groups; it takes additional signal, the destination, the timing, the HTTP layer above it, the IP reputation, to turn a bucket into an identification. Every serious bot-detection stack that uses TLS today treats it this way, as one input to a score rather than an allow/deny key.

Around the same time, a different pressure was reshaping the offense side. uTLS, the Refraction Networking fork of Go’s crypto/tls, shipped in this window to give Go programs low-level control over the ClientHello so they could mimic real browsers. The motivation was censorship circumvention: Go’s native handshake has a distinctive fingerprint that “especially sticks out on mobile clients,” and anti-censorship tools wanted to blend in rather than be blocked on a single recognizable hash. uTLS gave them parroted browser fingerprints and a roller that rotates through several, including randomized ones. The mechanics of that mimicry have their own post. The point for this timeline is that by 2020 the same primitive, control over the exact bytes of the ClientHello, was being used both to fingerprint clients and to defeat fingerprinting, and the two uses were developed by overlapping communities.

2020: JARM and going active

Everything to this point was passive. You watched handshakes go by and read what clients volunteered. JARM, which Salesforce released in November 2020, inverted that. Instead of fingerprinting a client from what it sends, JARM fingerprints a server by what it answers, and it does so by actively reaching out.

JARM sends ten specially crafted ClientHello packets to a target server and records how the server responds to each. The probes are deliberately varied, different TLS versions, different cipher orderings, different extension sets, designed to coax out differences in how servers negotiate. A server’s choices in response, which cipher it picks, which version it settles on, which extensions it echoes, depend on its TLS stack and configuration, so the aggregate of ten responses is a stable signature for that server build. The output is a 62-character hybrid fuzzy hash: the first 30 characters encode the cipher and TLS version chosen for each of the ten probes (with 000 marking a refusal to negotiate), and the remainder is a non-reversible hash of the cumulative extension data.

JARM: active server fingerprinting via 10 crafted probes JARM scanner ClientHello probe 1 ... ... ServerHello responses ... target TLS server 62-char hash: 30 chars of cipher/version choices + non-reversible extension hash *JARM probes rather than listens. Ten varied ClientHellos in, ten ServerHellos out, hashed into one server signature.*

The operational appeal was infrastructure hunting. Malware command-and-control servers, phishing kits, and red-team frameworks often run default or distinctive TLS configurations, so JARM-scanning the internet and clustering by hash surfaces servers that share a build even across unrelated IPs and domains. A Cobalt Strike default profile, a particular botnet panel, a specific reverse-proxy setup: each tends toward a consistent JARM. The catch, which defenders learned quickly, is that an active probe is detectable and a server operator can deliberately alter the configuration to change or blend the JARM. Active fingerprinting trades the passivity of JA3 for the ability to fingerprint things that never connect to you, and pays for it in detectability.

JARM also sharpened a distinction that had been blurry until then. Passive fingerprinting reads what a peer volunteers; active fingerprinting reads how a peer reacts to inputs it did not choose. The second is harder to spoof casually, because mimicking a target’s behavior across ten different probes means reproducing its negotiation logic, not just copying a static byte string. But it is also noisier as a defensive tool, because the scan itself is traffic the target can see, log, and adapt to. The two approaches sit at opposite ends of a trade that recurs throughout the field. A signal you collect passively is cheap and silent but only as rich as what the peer chose to reveal. A signal you collect actively is richer but announces itself, and anything that announces itself eventually gets gamed. JARM is the active pole; JA3, JA4, and the p0f lineage are the passive one, and the field has kept both because they fail in different conditions.

2023: Chrome randomizes extensions and breaks JA3

The break was scheduled, announced, and entirely deliberate. Starting in Chrome 110, rolling out from around 20 January 2023 after earlier trials in the 106-to-109 range, Chrome began randomizing the order of extensions in its ClientHello on every connection. The stated goal was anti-ossification, the same instinct behind GREASE: if servers cannot depend on a fixed extension order, they cannot build brittle logic around it, and Chrome keeps its freedom to change the handshake later.

The side effect demolished JA3. JA3 concatenates extensions in the order they appear in the packet, then hashes. Randomize that order and the hash changes on every single connection from the same browser build. A client advertising 16 extensions can, by permutation alone, produce up to 16 factorial distinct JA3 hashes, which is just under 21 trillion. The fingerprint that had been JA3’s whole value, one stable hash per client, became one hash per connection. For identifying a client across requests, JA3 was finished overnight, and every downstream system keyed on JA3 hashes inherited the problem at once.

There were two ways to respond, and both got tried. The narrow fix was normalization: sort the extension list before hashing so order stops mattering. Community work produced exactly this under the JA3N label and similar names, recovering a stable hash at the cost of throwing away whatever signal the original ordering carried. The broader fix was a new format designed from the start to be order-insensitive where order is noise and order-sensitive where order is signal. That is what FoxIO built.

2023: JA4 and the plus

John Althouse, the same person behind JA3 and JARM, published JA4+ at FoxIO on 26 September 2023, replacing the 2017 JA3 standard. JA4 is the TLS-client member of the suite, and its design reads as a direct answer to everything the previous six years had exposed.

The format is human-readable, then hashed. A JA4 fingerprint is three underscore-separated sections, written a_b_c. The first section is plaintext metadata you can read at a glance: the transport (t for TLS over TCP, q for QUIC, d for DTLS), a two-character TLS version, a flag for whether SNI was present (d for a domain in SNI, i for none), a two-digit count of cipher suites, a two-digit count of extensions, and the first and last alphanumeric characters of the first ALPN value. The second section is a 12-character truncated SHA-256 of the cipher list. The third is a 12-character truncated SHA-256 of the extensions plus signature algorithms. A real example is t13d1516h2_8daaf6152771_e5627efa2ab1, which decodes as TLS 1.3, SNI present, 15 ciphers, 16 extensions, ALPN h2, then the two hashes.

JA4: readable prefix, then two sorted-and-hashed sections t13d1516h2 _ 8daaf6152771 _ e5627efa2ab1 JA4_a · readable t = TLS over TCP 13 = TLS 1.3 d = SNI present 15 ciphers, 16 extensions h2 = first ALPN value JA4_b SHA-256 of cipher list, sorted in hex, first 12 chars JA4_c SHA-256 of sorted extensions + sig algs Sorting the ciphers and extensions before hashing is what makes JA4 survive Chrome's randomization. *The sort is the fix. Because JA4_b and JA4_c hash sorted lists, a randomized extension order produces the same fingerprint every time.*

Three design choices answer three earlier problems. Sorting the cipher and extension lists before hashing kills the randomization attack: a permuted order sorts back to the same sequence, so Chrome’s per-connection shuffle yields one stable JA4. GREASE values are stripped, as in JA3, and SNI and ALPN are excluded from the extension hash so that the same client to different hostnames does not fork into different fingerprints. And the readable prefix means an analyst can match on coarse features, TLS 1.3 with these counts and this ALPN, without committing to the full hash, or pivot on just one hash section. JA3’s opaque MD5 gave you a hash or nothing; JA4 gives you a small grammar.

The plus is the rest of the family, and it is the part that returns the field to the destination-context lesson from 2019. JA4S fingerprints the server’s response, the JA4 analogue of JA3S. JA4H fingerprints the HTTP client from a request’s method, headers, and cookies. JA4X fingerprints the way an X.509 certificate was generated, the structure rather than the values, useful for clustering certs minted by the same tooling. JA4L estimates light-distance locality, a latency measurement that approximates how physically far the peer sits. JA4T fingerprints the TCP stack, and JA4SSH fingerprints SSH sessions. The whole JA4+ suite has its own reference; the historical point is that FoxIO did not ship a better hash, it shipped a composable set that spans the stack from TCP to HTTP, so that a TLS fingerprint can be combined with the layers around it instead of standing alone.

Licensing changed too, and it caused friction. JA4 itself is BSD 3-Clause, freely usable. The other suite members, JA4S, JA4H, JA4X, JA4L, JA4SSH, ship under the FoxIO License 1.1, which permits academic and internal business use but reserves monetization, requiring an OEM license to build the methods into a product you sell. That is a deliberate departure from JA3’s unconditional open-sourcing, and it shapes who adopts which parts.

2024 onward: TLS fingerprinting as one signal among many

Adoption of JA4 followed the JA3 playbook but faster, because the consumers already had fingerprint-shaped slots to fill. Cloudflare moved to JA4 and, by August 2024, layered inter-request signals on top of it, aggregating per-fingerprint statistics over the trailing hour of global traffic, the share of requests that look browser-driven, the cache-hit ratio, the HTTP/2-and-3 ratio, request-volume quantiles, so that a fingerprint is scored against how the rest of the internet’s traffic with that same fingerprint behaves. Their own writeup is blunt about the reason: fingerprints “can be easily spoofed, they change frequently, and traffic patterns and behaviors are constantly” shifting, so the fingerprint is an index into behavioral statistics rather than a verdict by itself. How that feeds bot scoring is covered in the Cloudflare TLS and HTTP/2 fingerprinting post.

That is where the discipline sits now. The offense has industrialized: uTLS and the tools built on it, curl-impersonate and the various browser-mimicking HTTP clients, can reproduce a target browser’s exact ClientHello, which means a spoofed JA3 or JA4 is cheap to produce. Detecting the spoof has moved up and around the TLS layer, to whether the TLS fingerprint is consistent with the HTTP/2 SETTINGS frame and pseudo-header order, with the User-Agent, with the QUIC handshake when present, with the IP’s reputation. A client that presents Chrome’s JA4 but Go’s HTTP/2 frame ordering is more interesting than either signal alone. The fingerprint narrowed; the cross-checks multiplied.

The arms race also has a structural quirk worth stating. JA3’s strength and weakness were the same property: it was a single, opaque, perfectly shareable hash, which is why it spread to dozens of tools and why one Chrome release could break it everywhere at once. JA4’s grammar makes it more resistant to the specific break that killed JA3, but the deeper truth is the one Cisco documented in 2019 and Cloudflare re-stated in 2024. A TLS fingerprint groups software by its crypto configuration, and crypto configuration is shared across far more programs than anyone wants it to be. The fingerprint was never an identity. It was always a bucket, and the work has always been in what you condition on to turn a bucket into a name. p0f knew that about TCP stacks in 2000. The TLS version of the lesson just took two decades and one deliberate Chrome change to become unavoidable.


Sources & further reading

Further reading