TLS fingerprints (JA3 and JA4)
Why it matters Bot and fraud detection, and some trackers, recognise browsers by hashing their ClientHello. Writing the two best-known recipes yourself shows exactly how a browser can be recognised before any page loads, and why one browser change in 2023 broke the older recipe.
A fingerprint here is like describing someone by the exact order they list their skills. Write the list down, squeeze it into a short code, and you can compare codes instead of long lists.
Hash: a function that squeezes any text into a short fixed-size code. The same text always gives the same code; a different text gives a different code. MD5 and SHA-256 are two hash functions in Python's hashlib.
JA3: join everything in order, then hash
JA3, published in 2017, writes five fields as decimal numbers, joins each list with dashes, joins the five fields with commas, and takes the MD5 hash. GREASE values are left out.
version,ciphers,extensions,groups,point_formats
771,4866-4865-4867-49196,0-10-11-13-16,29-23-25-24,0 (shortened)
md5 -> a 32-character hex codeBecause it keeps the order, two browsers offering the same features in a different order get different JA3 codes. That was a strength, until browsers changed.
The 2023 change: shuffled extensions
From Chrome 110 (early 2023), Chrome began putting its TLS extensions in a random order on every connection (checked October 2026). The aim was to stop servers depending on one fixed order, which makes the protocol harder to change. A side effect: Chrome's JA3 code became different on almost every connection, so JA3 could no longer recognise it as one thing.
JA4: sort first, then hash
JA4, released in 2023, fixes that by sorting cipher suites and extensions before hashing, so shuffling makes no difference. It also starts with a short readable part. A JA4 code has this shape (the two hashes here are made up): t13d1516h2_1a2b3c4d5e6f_0f9e8d7c6b5a:
tfor TCP,13for TLS 1.3,dbecause a domain name was sent (iif not),15cipher suites,16extensions,h2from the first ALPN value's first and last characters.- Then the first 12 hex characters of a SHA-256 of the sorted cipher suites.
- Then 12 characters from the sorted extensions (without the server name and ALPN, which depend on the site, not the browser).
Our lab builds a simplified JA4-style code with the same three parts. Real JA4 also includes signature algorithms in the last part; we leave those out to keep it short. A licensing note for builders: JA4 (the TLS client fingerprint) is under the BSD licence, while most of the rest of the JA4+ family uses the FoxIO licence (checked October 2026).
Python you need
import hashlib
hashlib.md5("abc".encode()).hexdigest() # 32 hex characters
hashlib.sha256("abc".encode()).hexdigest()[:12] # first 12
"-".join(str(n) for n in [771, 4865]) # '771-4865'
"%04x" % 4865 # '1301' (4-digit hex)
"%02d" % 7 # '07'The practice world gives you client_hello(browser, connection, seed) for three fictional browsers: Brook (adds GREASE and shuffles its extensions), Cobalt (adds GREASE, fixed order) and Fennel (neither). Their numbers are real registry codes in combinations no real browser uses, so nothing here imitates a real browser. GREASE is the set of GREASE values.
In other languages
Fingerprinting tools in many languages compute JA3 and JA4. The JA3 core in a few of them:
// JavaScript (Node.js)
const crypto = require("crypto");
const clean = xs => xs.filter(x => !GREASE.has(x)).join("-");
const s = [h.version, clean(h.ciphers), clean(h.extensions),
clean(h.groups), clean(h.point_formats)].join(",");
const ja3 = crypto.createHash("md5").update(s).digest("hex");// Go
sum := md5.Sum([]byte(s))
ja3 := hex.EncodeToString(sum[:])
// sorting for JA4: sort.Slice(ciphers, func(i, j int) bool { return ciphers[i] < ciphers[j] })// Rust (sha2 crate)
use sha2::{Digest, Sha256};
let mut c = ciphers.clone();
c.sort();
let text = c.iter().map(|n| format!("{:04x}", n)).collect::<Vec<_>>().join(",");
let part_b = &format!("{:x}", Sha256::digest(text.as_bytes()))[..12];The defence
You cannot stop sending a ClientHello. What protects ordinary people is being common: an up-to-date, popular browser shares its fingerprint with millions of others, so it says little about you. An unusual setup, such as an old or heavily modified browser, stands out. Lesson 106 returns to this.
Recap
- JA3 joins the ClientHello fields in order and takes an MD5; it skips GREASE.
- Chrome began shuffling its extension order in Chrome 110 (early 2023), which made its JA3 change per connection.
- JA4 sorts cipher suites and extensions before hashing, so order changes do not matter.
- A common, up-to-date browser shares its fingerprint with many people, which is the real protection.
Each hello is a dict from client_hello(...) with version, supported_versions, ciphers, extensions, groups, point_formats, sni and alpn. Write four functions:
strip_grease(values): a new list without any value inGREASE, in the original order.ja3_string(hello):version, thenciphers,extensions,groupsandpoint_formats, each with GREASE removed and joined by-; the five fields joined by,.ja3(hello): the MD5 hex digest ofja3_string(hello).ja4_style(hello): three parts joined by_. Part a ist, then13if 772 is insupported_versionselse12, thendifsniis set elsei, then the cipher count and extension count (GREASE removed) as two digits each, then the first and last character of the first ALPN value (00if the list is empty). Part b is the first 12 characters of the SHA-256 hex digest of the sorted ciphers, each written as 4-digit hex ("%04x") and joined by,. Part c is the same for the sorted extensions, leaving out 0 and 16.
This lesson is locked
Lessons open one at a time. Finish the previous lesson to unlock this one.