● early access
Fingerprinting

Multi-Signal Request Fingerprints: Combining TLS, HTTP and Network

How to design a composite request fingerprint from TLS, HTTP and network signals: picking fields, hashing them safely, and keeping implementations in sync.

BannedRobots Teampublished 4 min read

A single signal rarely identifies a client well. TLS fingerprints are shared by every program built on the same library. User agents are trivially spoofed. Network identifiers cover millions of users. Combined, though, they become specific enough to single out one piece of client software without touching the users around it.

This article covers how to design a composite fingerprint: choosing fields, hashing them without subtle bugs, and keeping the fingerprint identical everywhere it’s computed. The field set below is an example to illustrate the trade-offs. The right set depends on your traffic and on what your edge exposes.

Choosing the fields

A good fingerprint field is stable for a given client across requests, varies between different client software, and is available on every request. Candidates tend to come from four layers:

Layer Example signals What it captures
TLS JA4 or similar ClientHello hash TLS library and configuration
HTTP Protocol version, HTTP/2 settings, header order, which optional headers are present HTTP client implementation
Claimed identity User-Agent family, client hints What the client says it is
Network ASN or network type Where the traffic comes from

Including claimed identity may seem odd, since it’s easy to spoof. But inside a composite fingerprint it adds precision instead of trust. A client that claims to be a current browser while its TLS and HTTP layers look like a scripting library produces a combination no real browser sends, and that inconsistency is itself informative.

Leave out anything that changes per request or per session, such as IP address, cookies, request IDs, timestamps or Referer. They make every fingerprint unique and useless for blocking.

Hash a canonical form, not a concatenation

The obvious implementation is to join fields with a separator and hash the result:

# Don't do this
raw = "|".join([tls, http, ua, network])

The problem is ambiguity. User agents and other headers are client-controlled strings and can contain your separator. Two different field combinations can then produce the same string and the same hash, a silent collision in your block list.

The fix is to serialize into a format with unambiguous quoting and a fixed key order. JSON with sorted keys and fixed separators works well:

import hashlib, json

def fingerprint(fields: dict) -> str:
    canonical = {k: normalize(k, v) for k, v in fields.items()}
    raw = json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()

Most of the real work is in normalize:

  1. Consistent casing and whitespace. Lowercase and trim values that clients format inconsistently. Leave values that are already canonical, such as hashes, alone.
  2. One representation for “missing”. An absent header must always map to the same value. Don’t let one code path produce null and another "".
  3. Type coercion. Identifiers like ASNs arrive as numbers from some sources and strings from others. Pick one representation.
  4. Bounded values. Very long or high-cardinality values (full user-agent strings with build numbers, say) can make fingerprints too specific. Reducing them to a family and major version is often better.

Parity: the bug that produces no errors

In most real systems the fingerprint is computed in two places. An offline pipeline decides which fingerprints to block, and an edge runtime fingerprints each live request and looks it up. These are often in different languages.

If the two implementations differ in any detail (key order, casing, a default value, Unicode handling), the hashes won’t match. Nothing crashes. Blocks silently stop working.

The edge side in JavaScript follows the same shape:

async function fingerprint(fields) {
  const canonical = Object.fromEntries(
    Object.keys(fields).sort().map((k) => [k, normalize(k, fields[k])]),
  );
  const data = new TextEncoder().encode(JSON.stringify(canonical));
  const digest = await crypto.subtle.digest('SHA-256', data);
  return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
}

Note that JSON.stringify keeps insertion order, so sort the keys explicitly. Python’s ensure_ascii=False matters too: without it, non-ASCII characters are escaped in Python but not in JavaScript, and the hashes diverge.

Protect parity with a golden test: a shared fixture file of raw inputs and their expected hashes, run against both implementations in CI. Include awkward inputs: missing fields, null, numbers as strings, non-ASCII characters, and values containing quotes and separators.

One more trap is formatting at the source. If your logging pipeline records the HTTP protocol as h2 while the edge sees HTTP/2, every hash differs even though both implementations are “correct”. Normalize at ingestion, or fingerprint from the same raw field in both places.

Specificity: the central trade-off

More fields make a fingerprint more specific:

  • Too broad (TLS alone): blocking it catches every client on the same library, including legitimate scripts and some real users.
  • Too narrow (volatile fields): every request looks unique, and the fingerprint stops identifying anything.

Finding the right balance takes measurement on your own traffic: how many distinct fingerprints a known client produces over a day, and how many unrelated clients share each one.

Key takeaways

  • Combine signals from several layers: TLS, HTTP, claimed identity and network. Leave out per-request values.
  • Hash a canonical serialization (sorted-key JSON), never a delimiter-joined string.
  • Define normalization once and apply it everywhere, and protect cross-language parity with shared test vectors.
  • Tune specificity on your own traffic: specific enough to spare real users, stable enough to keep identifying the same client.
fingerprintingsha-256canonicalizationhttp headersasn