DEV Community

Greta
Greta

Posted on

Understanding HTTP/2 Fingerprinting: What Your Scraper Leaks Above the TLS Layer

Understanding HTTP/2 Fingerprinting: What Your Scraper Leaks Above the TLS Layer

Here's a bug report I've received more than once: a scraping pipeline was migrated to a TLS-impersonating client, the JA3/JA4 hash now matched Chrome byte-for-byte, headers were copied straight from DevTools — and the block rate didn't move. Same 403s, same challenge pages, occasionally even worse behavior than before.

The TLS layer wasn't the problem. The problem is that TLS fingerprinting got all the attention, and the HTTP/2 layer sitting right above it is just as identifying. An anti-bot system that already sees a Chrome-shaped ClientHello can compare the very next frame your client sends — the HTTP/2 SETTINGS frame — against what Chrome actually sends. When that frame looks like Python, the contradiction is more suspicious than a plain Python client, because now you look like something deliberately dressed up as Chrome.

That's the thesis of this post: your fingerprint is TLS + HTTP/2 + headers as one unit, and the HTTP/2 layer is where fingerprint-consistent scrapers most often fall apart.

What Actually Goes Into an H2 Fingerprint

After the TLS handshake completes and ALPN negotiates h2, the first thing an HTTP/2 client does is send a SETTINGS frame. It's a small key-value list of connection parameters, and browsers are surprisingly opinionated about it:

  • SETTINGS_HEADER_TABLE_SIZE (0x1) — HPACK dynamic table size. Chrome sends 65536.
  • SETTINGS_ENABLE_PUSH (0x2) — usually 0, since server push is dead.
  • SETTINGS_MAX_CONCURRENT_STREAMS (0x3) — Chrome sends 1000.
  • SETTINGS_INITIAL_WINDOW_SIZE (0x4) — flow control window per stream. Chrome sends 6291456 (6 MB).
  • SETTINGS_MAX_HEADER_LIST_SIZE (0x6) — Chrome sends 262144.

That's not all. The full "Akamai-style" HTTP/2 fingerprint that anti-bot vendors catalog consists of four parts:

  1. The SETTINGS frame — which settings are sent, their values, and (in stricter formulations) their order.
  2. A connection-level WINDOW_UPDATE frame. Chrome immediately cranks the connection window from 65535 to 15728640 by sending a window increment of 15663105. Most Python clients never send one at all.
  3. PRIORITY frames (or priority information in HEADERS frames). Chrome sends a small cascade of PRIORITY frames for stream 0 and odd-numbered streams with specific dependency trees and weights. Python's h2 library doesn't send PRIORITY frames by default — this alone is a dead giveaway.
  4. Pseudo-header order. The request headers :method, :authority, :scheme, :path must come first, but HTTP/2 does not mandate their order. Chrome sends :method, :authority, :scheme, :path. httpx sends :method, :scheme, :authority, :path. The order is visible to the server in the HPACK-encoded HEADERS frame, and it's stable per implementation — which makes it a fingerprint.

None of these values are secrets, and none of them are negotiated. The client just declares them, and the server just reads them. It's a passive, cheap, reliable identification signal that requires zero JavaScript.

Chrome vs. the Python Default Stack

Here's the comparison that got me blocked more than any TLS mismatch:

Signal Chrome (recent) httpx / hyper-h2 default
HEADER_TABLE_SIZE 65536 4096 (or omitted)
MAX_CONCURRENT_STREAMS 1000 omitted (unlimited)
INITIAL_WINDOW_SIZE 6291456 65535 (default)
MAX_HEADER_LIST_SIZE 262144 omitted
Connection WINDOW_UPDATE +15663105 not sent
PRIORITY frames cascade for streams 0,3,5,7,9,11,13 not sent
Pseudo-header order m,a,s,p m,s,a,p
Header ordering overall stable, browser-specific insertion order (dict order)

Every row in the right column is a "this is a Python program" beacon. And unlike TLS, you can't fix it by swapping the cipher list — these values are decided by the HTTP/2 library code, not by OpenSSL configuration.

Why is the Python stack like this? Because hyper-h2 is a correct implementation of RFC 7540, not an impersonation of a browser. It sends minimal settings, relies on protocol defaults, and treats priority as optional (which the RFC allows — priority information is advisory). Chrome's values, meanwhile, are the product of a decade of Google's performance tuning plus years of shipped legacy behavior frozen in place because millions of servers have come to expect exactly that pattern. The anti-bot databases catalog the Chrome pattern; RFC-compliant minimalism reads as "automation."

Inspect Your Own H2 Fingerprint

The easiest way to see this is the same trick as with TLS fingerprints: ask a server that echoes what it sees. tls.peet.ws/api/all returns your HTTP/2 fingerprint alongside the TLS one:

# pip install httpx[http2] curl_cffi

import httpx
from curl_cffi import requests as crequests

# What plain httpx (hyper-h2) looks like over HTTP/2
r = httpx.Client(http2=True).get("https://tls.peet.ws/api/all").json()
print("httpx :", r["http2"]["akamai_fingerprint"])

# What a Chrome-impersonating client looks like
r2 = crequests.get("https://tls.peet.ws/api/all", impersonate="chrome124").json()
print("cffi  :", r2["http2"]["akamai_fingerprint"])
Enter fullscreen mode Exit fullscreen mode

Run that and you'll see two strings like:

httpx : 1:4096;2:0|0|0:0:0:0:0:0:0:0|m,s,a,p
cffi  : 1:65536;2:0;3:1000;4:6291456;6:262144|15663105|0:0:0:0:1:0:0:0:0|m,a,s,p
Enter fullscreen mode Exit fullscreen mode

The format is settings|window_update|priority_frames|pseudo_header_order. Two clients, same machine, same IP, same TLS story — and completely different HTTP/2 identities. The second one is what the server's risk engine wants to see next to a Chrome ClientHello.

Computing the Fingerprint Yourself

If you want to understand the mechanics rather than trust an echo endpoint, here's a compact implementation of the Akamai-style fingerprint computation from captured frame data:

import hashlib

def h2_fingerprint(
    settings: dict[int, int],          # from the client's SETTINGS frame
    window_update: int | None,         # connection-level WINDOW_UPDATE increment
    priorities: list[str],             # ["stream:depends:exclusive:weight", ...]
    pseudo_order: list[str],           # e.g. [":method", ":authority", ":scheme", ":path"]
) -> str:
    ids = sorted(settings)
    settings_part = ";".join(f"{i}:{settings[i]}" for i in ids)
    wu_part = str(window_update or 0)
    prio_part = "|".join(priorities) if priorities else "0"
    pseudo_part = ",".join(p[1] for p in pseudo_order)  # "m,a,s,p"
    raw = f"{settings_part}|{wu_part}|{prio_part}|{pseudo_part}"
    return raw

chrome = {
    1: 65536,    # HEADER_TABLE_SIZE
    2: 0,        # ENABLE_PUSH
    3: 1000,     # MAX_CONCURRENT_STREAMS
    4: 6291456,  # INITIAL_WINDOW_SIZE
    6: 262144,   # MAX_HEADER_LIST_SIZE
}
print(h2_fingerprint(chrome, 15663105, ["0:0:0:0:1:0:0:0:0"],
                     [":method", ":authority", ":scheme", ":path"]))
# 1:65536;2:0;3:1000;4:6291456;6:262144|15663105|0:0:0:0:1:0:0:0:0|m,a,s,p
Enter fullscreen mode Exit fullscreen mode

One honest caveat from experience: different vendors serialize this slightly differently (some hash the string with MD5, some include the SETTINGS frame order rather than sorted IDs, some record priority info inline in HEADERS frames as stream;excl;depends;weight groups). The invariant across all of them is the input: settings values, window update, priorities, pseudo-header order. Capture those four correctly and you can compute any vendor's variant.

If you want to see raw frames rather than trust anyone's summary, you can drive hyper-h2 directly over a socket and log every frame you emit:

# pip install h2
import socket, ssl, h2.config, h2.connection

ctx = ssl.create_default_context()
ctx.set_alpn_protocols(["h2"])
sock = ctx.wrap_socket(socket.create_connection(("tls.peet.ws", 443)),
                       server_hostname="tls.peet.ws")

conn = h2.connection.H2Connection(
    config=h2.config.H2Configuration(client_side=True)
)
conn.initiate_connection()
sock.sendall(conn.data_to_send())

# The next bytes on the wire are exactly your SETTINGS frame —
# inspect conn.local_settings to see what you just announced.
print(dict(conn.local_settings))

while True:
    data = sock.recv(65535)
    if not data:
        break
    events = conn.receive_data(data)
    for e in events:
        print(type(e).__name__, getattr(e, "stream_id", ""))
    out = conn.data_to_send()
    if out:
        sock.sendall(out)
Enter fullscreen mode Exit fullscreen mode

On a stock install, local_settings is a graveyard of RFC defaults — no 6 MB window, no connection-level window bump, no priorities. That output is your h2 fingerprint problem, printed in dict form.

Why a Perfect TLS Fingerprint Still Gets Flagged

Here's the part that took me embarrassingly long to internalize: anti-bot systems don't score signals independently, they score consistency. A Chrome JA3 followed by a Chrome-shaped SETTINGS frame followed by Chrome-ordered headers is one coherent story. A Chrome JA3 followed by a hyper-h2 SETTINGS frame is two stories glued together, and the seam is exactly what the risk model is trained on.

Think about what the mismatch tells the defender. A Python client that didn't touch TLS is a low-effort scraper — maybe tolerable, maybe worth a soft block. A client that forged its TLS handshake but forgot the HTTP/2 layer is deliberately evading detection, which is the highest-score category there is. I've seen cases where fixing the h2 layer on an already-TLS-consistent client dropped block rates dramatically — the TLS work was never wasted, it was just incomplete.

The fix, in order of effort:

  1. curl_cffi with impersonate — it matches Chrome's HTTP/2 settings, window update, and pseudo-header order alongside the TLS fingerprint, in one flag. For most scraping this is the whole answer.
  2. tls-client — same idea, similar coverage of the h2 layer; pick by ergonomics.
  3. Raw h2 with manual configuration — when you need a fingerprint that no library ships (an older Chrome, an embedded WebView, an Android client), you configure local_settings, send the connection WINDOW_UPDATE yourself, emit PRIORITY frames, and control header emission order. Maximum control, maximum maintenance — you now own browser-version drift.

Whichever route you take, verify the whole stack, not one layer: compare your JA3/JA4, your Akamai h2 fingerprint, and your header order against a real browser session on the same endpoint. Any one of the three mismatching means the other two were wasted effort.

One Story, Every Layer

The mental model that finally made this stick: every request you send is a single claim — I am this browser, on this connection, asking for this page. TLS, HTTP/2, and headers are just three chapters of the same claim, and a reviewer (human or model) only needs to catch one contradiction to reject the whole thing. The TLS layer got famous because it was the first contradiction scrapers hit. The HTTP/2 layer is the second, and the scrapers getting blocked today are overwhelmingly the ones that fixed chapter one and called the book finished.

Match the SETTINGS values. Send the window update. Order the pseudo-headers. Then, and only then, does the Chrome story hold together.


Disclosure: I use Thordata's residential proxies as the network layer under the fingerprint-consistent clients described in this post. If you want to try them, they're at thordata.com, and the code **thor020* gets you 10% off.*

Top comments (0)