The easiest way to misunderstand network fingerprints is to compare example strings taken from different clients. We wanted a cleaner test: one packet capture, one TLS ClientHello and three fingerprint formats.
The complete lab is checked into this site's source under labs/network-fingerprinting/, and the publication bundle is available here. It pins the tool revisions, reconstructs the fixture, verifies its checksum, runs the tools and checks that their outputs refer to the same connection. Nothing in the comparison depends on a vendor database or an application label.
The input
The fixture is a 329-byte Peakhour-generated capture containing a local OpenSSL 3.5.6 ClientHello wrapped in one synthetic Ethernet/IPv4/TCP packet:
10.1.1.1:40000 -> 10.2.2.2:443
SNI: lab.peakhour.test
The lab stores the small fixture as base64 under an adjacent BSD 3-Clause licence and verifies the decoded PCAP with SHA-256 before using it. The reserved SNI and private addresses did not cross a network. We select packet 1 and TCP stream 0. That selection matters: saying that several tools read the same PCAP is weaker than proving that their output describes the same flow and ClientHello.
The pinned revisions for this run are:
Cisco Mercury 3172786645f70e1a8347d8cf020b736e185651e5
FoxIO JA4 0e54bc8371de34df94a35f2442c05bda2e8b2034
Salesforce JA3 502cc6395811c54743b0561419d61900a6df3ff7
These pins are part of the result. Fingerprint implementations and specifications change. A value without its method and version is harder to reproduce than it first appears.
Running the lab
From the repository root:
./labs/network-fingerprinting/run.sh
python labs/network-fingerprinting/verify.py
The runner fetches the pinned source archives, builds or invokes the implementations in a temporary work directory, and writes evidence to labs/network-fingerprinting/results/. The verifier checks the fixture and output checksums, connection tuple, SNI and output shape. The pinned source URLs are enforced by the runner rather than inferred by the verifier.
This is a fingerprint-format lab, not a speed test. Build time, runtime and memory use depend heavily on language, wrapper and capture path, so we do not compare them here.
JA3: a portable exact-match digest
For this ClientHello, the canonical JA3 feature string is:
771,49196-49200-159-52393-52392-52394-49195-49199-158-49188-49192-107-49187-49191-103-49162-49172-57-49161-49171-51-157-156-61-60-53-47,65281-0-11-10-35-16-22-23-13,29-23-30-24-25,0-1-2
Its MD5 digest is:
e1934f32e97b0bd52227953ca7d30118
The digest is convenient for logs and exact lookup. On its own it does not show which cipher, extension or group changed. The pre-hash string retains enough information to investigate that difference, which is why throwing it away too early can make later analysis harder.
JA3 removes GREASE values but otherwise retains the order of its selected lists. A client that permutes extension order can therefore generate a new JA3 digest without changing its effective TLS capabilities. The archived Salesforce JA3 repository defines the input fields and GREASE handling.
JA4: canonicalised components
The same ClientHello produces this JA4:
t12d2709h2_a2460661a67a_36cef8aed422
Its first section is readable:
tmeans TLS over TCP;12is the highest supported TLS version after ignoring GREASE;dsays a domain was present in SNI;27and09are the cipher and extension counts after the format's exclusions;h2summarises the first ALPN value.
The second section is the first 12 hexadecimal characters of SHA-256 over sorted cipher identifiers. The third is a truncated SHA-256 value derived from sorted extension identifiers and the signature algorithms in their original order. The canonical JA4 technical specification defines the exact exclusions and encodings.
The lab also records JA4_r, the raw form used by the FoxIO tooling:
t12d2709h2_002f,0033,0035,0039,003c,003d,0067,006b,009c,009d,009e,009f,c009,c00a,c013,c014,c023,c024,c027,c028,c02b,c02c,c02f,c030,cca8,cca9,ccaa_000a,000b,000d,0016,0017,0023,ff01_0403,0503,0603,0807,0808,0809,080a,080b,0804,0805,0806,0401,0501,0601,0303,0301,0302,0402,0502,0602
That makes the normalisation visible. It also shows why JA4 is not fuzzy hashing: sorting makes selected permutations equivalent, while the hashes still support equality matching rather than semantic distance.
Mercury NPF: a retained protocol tree
Cisco Mercury 2.18 emits this tls/2 fingerprint for the same ClientHello:
tls/2/(0303)(c02cc030009fcca9cca8ccaac02bc02f009ec024c028006bc023c0270067c00ac0140039c009c0130033009d009c003d003c0035002f)[(0000)(000a000c000a001d0017001e00180019)(000b000403000102)(000d002a0028040305030603080708080809080a080b080408050806040105010601030303010302040205020602)(0010000e000c02683208687474702f312e31)(0016)(0017)(ff01)]
The value is longer because it is doing another job. Parentheses and square brackets describe an ordered tree of selected byte strings. In the draft NPF notation, square brackets mark a lexicographically sorted list. The tls/2 prefix names the protocol and fingerprint rule version.
An analyst can inspect the retained values rather than relying only on a digest. Mercury also defines a compact hash nickname when a fixed-length index is needed, but that nickname loses the structure used for inspection, prefix comparison or approximate matching. Cisco's draft NPF specification documents both representations.
The Mercury JSON includes the same source and destination tuple and the same SNI as the JA3 and JA4 records. It does not identify the client application in this lab because we did not run a labelled fingerprint knowledge base or the destination-context classifier. A packet-derived NPF value and a process assessment are separate outputs.
What the comparison establishes
All three methods observe the same ClientHello, but they define similarity differently.
| Question | JA3 | JA4 | Mercury NPF tls/2 |
|---|---|---|---|
| Compact default | Yes | Yes | No; optional hash available |
| Inspectable selected inputs | Only if the pre-hash string is retained | Partly in a; fully in the recorded raw form |
Yes in the full tree |
| Selected list sorting | No, after GREASE removal | Ciphers and most extensions | Rule-specific selected extensions |
| Explicit format version in value | No | Encoded field semantics, but no separate rule number | Yes |
| Semantic or approximate comparison | Not from the digest | Component grouping, not hash distance | Full structure can support richer matching |
| Application attribution in the format | No | No | No |
The table does not produce a universal winner. JA3 remains useful where historical compatibility matters. JA4 is compact and handles selected permutations cleanly. Mercury retains more material for inspection and for analysis systems that need structured features.
It also shows what none of the values can establish. The capture does not prove which person, device or application created the connection. Shared libraries, browser impersonation and software updates all complicate that inference. See A network fingerprint is a cohort, not a client for the operational consequences.
For the history behind these design choices, read Two lineages of TLS fingerprinting. The durable format comparison is in Mercury vs JA4 vs JA3.