Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Fingerprints

A browser profile is a recorded description of how one browser build looks on the wire. Leyline replays it across four layers. This page covers what a profile pins, how to audit and check a session’s fingerprint, which headers are safe to override, and how to mix and rotate identities.

What a profile pins

LayerHolds
TLS (TlsProfile)The cipher list, supported groups, signature algorithms, certificate compression, and extension behavior: GREASE, ECH GREASE, extension permutation or a fixed order, ALPS, record size limit, delegated credentials, OCSP stapling, signed certificate timestamps, session tickets, pre-shared key, padding, trust anchor requests, and the minimum TLS version
HTTP/2 (H2Profile)The SETTINGS values and their order, the pseudo-header order, the default priority frame, and per-platform overrides
HeadersThe User-Agent and sec-ch-ua of each platform block. The header style in profiles/headers.toml and the Preset decide the other headers and their order. See Requests
TCP (TcpProfile)The socket options that shape the SYN: TTL, MSS, window size, window scale, the don’t-fragment bit, and TCP_NODELAY

The session takes its TcpProfile from profiles/platforms.toml through Platform::tcp_profile; SessionBuilder::tcp_profile overrides it. Each OS applies what it can:

OSApplies
LinuxTTL, MSS, don’t-fragment, and a TCP_WINDOW_CLAMP derived from the window fields
macOSTTL, MSS, and don’t-fragment
WindowsTTL and don’t-fragment

Every OS sets TCP_NODELAY when the profile asks for it. The SYN option order in options is chosen by the kernel and cannot be set from a socket, so only the audit’s JA4T reads it. platforms.toml has rows for Windows, macOS, Linux, and iOS. Android has none, so its TcpProfile is empty and the kernel defaults apply.

Audit a session

Build the session with .audit(true), then read Response::audit(). Without the flag, audit() returns None.

#![allow(unused)]
fn main() {
use leyline::{Browser, Platform, Session};

async fn run() -> leyline::Result<()> {
let session = Session::builder()
    .browser(Browser::Chrome152)
    .platform(Platform::Linux)
    .audit(true)
    .build()?;

let resp = session.get("https://example.com/").await?;
if let Some(a) = resp.audit() {
    println!("JA4 {} JA4H {} H2 {}", a.ja4, a.ja4h, a.h2_fingerprint);
}
Ok(())
}
}
AuditData fieldWhat it is
ja3JA3, the MD5 form. It hashes the extensions in the order sent
ja4JA4. It sorts the extensions before it hashes them
ja4tJA4T from the session’s TcpProfile values, including fields the running OS does not apply
ja4hJA4H over the request line and headers of this response’s request. Computed on the first audit() call and cached
h2_fingerprintThe Akamai HTTP/2 fingerprint
permutes_extensionstrue when the profile shuffles the TLS extension order on each connection, as Chrome does
request_headersThe headers the session prepared for the request, in order, before the transport sent them

The connection fields are computed once per session from the profile. They report what Leyline built; a packet capture shows what the connection sent, and the kernel decides what the SYN carries.

Check a fingerprint with an echo service

A TLS echo service answers with the fingerprints of the connection it received. AuditData::compare checks them against the audit of the same session.

audit::Observed::from_json reads tls.ja4, tls.ja3_hash, http2.akamai_fingerprint, and the echoed request headers from http2.sent_frames[*].headers and http1.headers: the shape that https://tls.peet.ws/api/all returns. A missing field stays None. For another service, build it with Observed::new().ja4(..).ja3(..).h2_fingerprint(..).header(name, value).

#![allow(unused)]
fn main() {
use leyline::audit::{FieldOutcome, Observed};
use leyline::{Browser, Session};

async fn run() -> leyline::Result<()> {
let session = Session::builder()
    .browser(Browser::default())
    .audit(true)
    .build()?;
let resp = session.get("https://tls.peet.ws/api/all").send().await?;
let Some(audit) = resp.audit().cloned() else {
    return Ok(());
};
let observed = Observed::from_json(&resp.text().await?)?;
let report = audit.compare(&observed);
println!("match: {}", report.is_match());
if let FieldOutcome::Mismatch { expected, observed } = &report.ja4 {
    println!("ja4 sent {expected}, service saw {observed}");
}
for header in &report.headers {
    if let FieldOutcome::Mismatch { expected, observed } = &header.outcome {
        println!("{} sent {expected}, service saw {observed}", header.name);
    }
}
Ok(())
}
}

compare returns a FingerprintReport with a FieldOutcome for ja4, ja3, h2_fingerprint, and header_order, and a HeaderOutcome (lowercase name and outcome) in headers for each header the session sent, pseudo-headers excluded. header_order compares the order of the headers that both lists hold.

FieldOutcomeMeaning
MatchThe values are equal, ignoring ASCII case
Mismatch { expected, observed }The values differ, and they must not
Informational { expected, observed }The values differ, and they can
NotReportedThe service did not report the field, or reported no headers at all
Absent { expected }The session sent the header and the service reports headers, but not this one. It counts as a mismatch

JA4 and the Akamai HTTP/2 fingerprint are fixed for every profile, so a difference is a Mismatch. JA3 hashes the extensions in the order sent, and Chrome shuffles that order, so for a profile with permutes_extensions a JA3 difference is Informational; for a fixed-order profile such as Firefox or Safari it is a Mismatch. A header that a proxy or middlebox rewrites shows as a Mismatch. A proxy that ends TLS changes the result.

is_match() is true when nothing is a Mismatch or Absent; FieldOutcome::is_mismatch() tests one field. Both types implement Display: println!("{report}") prints one line per field and header.

Override headers safely

On a browser session, a header you set replaces the profile’s header of the same name in its position; Requests gives the placement of a new name.

HeaderEffect of an override
accept-languageSafe. Prefer languages
refererSafe. RequestBuilder::initiator is better: it also sets origin and sec-fetch-site
authorization, content-type, application headers such as x-api-keySafe
cookieSafe. The jar adds no cookies to that request
user-agentChanges the fingerprint. The sec-ch-ua* hints still come from the profile, so the two can disagree
sec-ch-ua*, accept-encoding, header orderChanges the fingerprint
acceptChanges the fingerprint on a navigation. See Set accept on a script request
sec-fetch-*Your value replaces the computed one. Use preset and initiator instead
content-lengthComputed by Leyline. Your value is replaced
hostComputed from the URL. Your value is kept on HTTP/1.1 and dropped on HTTP/2 and HTTP/3

Set accept on a script request

Keep the profile’s accept on open, follow, and the navigation presets. A page script sets its own accept on a fetch() or an XHR, for example application/json. The Xhr preset sends what a fetch() without an explicit accept sends. When the site’s script sets one, set the same value: the browser sends it too, so the fingerprint does not change.

#![allow(unused)]
fn main() {
async fn run(session: leyline::Session) -> leyline::Result<()> {
let tab = session.tab();
tab.open("https://shop.example/cart").await?;
let cart = tab
    .xhr("/api/cart")
    .header("accept", "application/json")
    .send()
    .await?;
let _ = cart;
Ok(())
}
}

Mix and rotate identities

An Identity holds two browsers and a platform. The tls() browser supplies the ClientHello, the HTTP/2 and HTTP/3 settings, and the ja3, ja4, and h2_fingerprint audit values. The http() browser supplies the User-Agent, sec-ch-ua, Accept-Language, the header shape, and the header order. The platform selects the OS that the User-Agent names, the HTTP/2 platform overrides, and the TcpProfile.

Functiontls() sidehttp() sideKind::Config when
Identity::locked(browser, platform)browserbrowserNever; the build checks the platform
rotate_tls(tls)tlsunchangedtls is of another Family than http()
rotate_hello()the next ClientHello of the familyunchangedThe family has one ClientHello
switch_family(dest)destdestdest is of the same Family, or has no identity for the platform

rotate_hello moves through Browser::all() order and wraps. A profile can share its ClientHello with another version; its [meta] table names that version in hello, tls() returns it, and rotate_hello counts shared ClientHellos once.

Build a session from an identity

SessionBuilder::identity(identity) sets both browsers and the platform. This session sends the headers of Chrome 154 on Linux over the ClientHello, HTTP/2 settings, and HTTP/3 settings of Chrome 152:

#![allow(unused)]
fn main() {
use leyline::{Browser, Identity, Platform, Session};

fn run() -> leyline::Result<()> {
let identity = Identity::locked(Browser::Chrome154, Platform::Linux)
    .rotate_tls(Browser::Chrome152)?;
let session = Session::builder().identity(identity).build()?;
assert_eq!(session.identity().browser(), Some(Browser::Chrome154));
Ok(())
}
}

browser, profile, and identity replace each other; the last call wins. browser and profile keep the platform in either order. identity sets the platform too, so a platform call before it has no effect and one after it replaces the identity’s platform. build() returns Kind::Config when the http() browser has no identity for the platform, for example Safari 26 on Windows. A session built with SessionBuilder::profile sends that one profile on both sides.

Change the identity of a session

Session::with_identity(identity) derives a session that sends another identity; the parent keeps its own. The derived session takes the ClientHello, the HTTP/2 and HTTP/3 settings, the headers, and the audit values from the identity, as SessionBuilder::identity does.

PartIn the derived session
Connection poolShared with the parent, in its own partition, so connections never cross identities
HSTS store and Alt-Svc knowledgeShared with the parent
TLS session cacheIts own, empty
Cookie jarThe parent’s. Give it another with with_cookie_jar
Brand, proxy, timeouts, TcpProfile, other settingsKept

A user-agent header from SessionBuilder::headers still replaces the identity’s User-Agent. Session::identity() reports the identity the derived session sends. See Sessions.

#![allow(unused)]
fn main() {
use leyline::{Browser, Family, Identity, Platform, Session};

fn run() -> leyline::Result<()> {
let chrome = Identity::locked(Browser::latest(Family::Chrome), Platform::Linux);
let session = Session::builder().identity(chrome).build()?;

let next_hello = session.with_identity(chrome.rotate_hello()?)?;

let firefox = chrome.switch_family(Browser::latest(Family::Firefox))?;
let switched = session.with_identity(firefox)?;
let _ = (next_hello, switched);
Ok(())
}
}

with_identity returns Kind::Config when:

  • The session has no Browser: a plain session, or one built with SessionBuilder::profile.
  • The http() browser has no identity for the platform.
  • The session brand, Edge or Opera, has no overlay for the http() browser on the platform. Only Chromium browsers have one.
  • The policy is ProtocolPolicy::Http3 or Race and the tls() browser has no [h3] table. Under Auto, Http1, or Http2 that is accepted. See HTTP/3.

Read and test the bundled profiles

ProfileRegistry::global().get(name, version) looks up a bundled profile; Browser::profile() is the short path. The profile reference lists what ships and how to load your own.

#![allow(unused)]
fn main() {
use leyline::Browser;
use leyline::profile::ProfileRegistry;

let profile = ProfileRegistry::global()
    .get("chrome", 152)
    .expect("chrome 152 is bundled");
assert_eq!(profile.meta.version, 152);
assert_eq!(Browser::Chrome152.profile().meta.browser, "chrome");
}

The offline conformance test checks each profile’s JA4 and HTTP/2 values against its recorded reference values without the network. It needs the bench-internals feature:

cargo test -p leyline-http --features bench-internals --test it fingerprint_conformance::

It checks the profile data against the reference values; it does not capture a browser.

Next

Read Features and targets.