HTTP/2 fingerprinting and how to defeat it for scraping
HTTP/2 fingerprinting is the layer of bot detection that catches scrapers after they have already spent effort fixing TLS. You spent a week migrating from requests to curl_cffi, your JA4 matches Chrome 124 perfectly, and you still get challenged on every third request. The reason is that Cloudflare and Akamai are also reading your HTTP/2 SETTINGS frame, your initial WINDOW_UPDATE, your header pseudo-header order, and your priority frames. Each of those carries an implementation signature, and the combined fingerprint is harder to forge than the TLS one.
This guide covers what HTTP/2 fingerprinting actually inspects, how Akamai’s HTTP/2 hash is constructed, what your stack emits today, and the realistic bypass paths in 2026. Code samples are working, the captures are real, and the comparison tables let you pick your library on what it actually does instead of what it claims.
Why HTTP/2 leaks more than scrapers expect
HTTP/2, specified in RFC 9113, is a binary multiplexed protocol. Every connection starts with a connection preface, followed by a SETTINGS frame, followed by stream activity. The SETTINGS frame announces parameters like header table size, maximum concurrent streams, initial window size, and maximum frame size. Each implementation picks defaults, and those defaults differ enough that a server can identify the client just by reading the first 24 bytes after the preface.
Beyond the initial SETTINGS, the entire connection lifecycle is rich with fingerprintable behavior:
- SETTINGS frame parameter order: real Chrome sends six parameters in a specific order, Firefox sends them in a different order, and Python httpx sends them in a third order
- Initial WINDOW_UPDATE size: Chrome sends 15663105, Firefox sends 12517377, httpx sends 65536
- PRIORITY frames or stream priority: Chrome 124 uses RFC 9218 priority signals, older clients use deprecated dependency trees
- Header pseudo-header order: Chrome orders
:method,:authority,:scheme,:path. Other clients use different orders - Header compression behavior: HPACK table sizing and dynamic table updates differ across implementations
- PUSH_PROMISE handling: rare in 2026 since server push was deprecated, but still part of behavior signatures
- GOAWAY and RST_STREAM patterns: how a client closes streams differs between libraries
Akamai built a fingerprinting scheme that captures these into a single string in the format S{settings}|{window_update}|{priorities}|{headers}. That string is what Akamai Bot Manager logs and what most modern bot-detection vendors compute via their own equivalent.
A real Chrome 124 HTTP/2 fingerprint
Captured from a Chrome 124 stable connection to a public test site, decoded:
Akamai HTTP/2 fingerprint:
1:65536;2:0;3:1000;4:6291456;6:262144|15663105|0|m,a,s,p
Decoded:
SETTINGS:
HEADER_TABLE_SIZE (1) = 65536
ENABLE_PUSH (2) = 0
MAX_CONCURRENT_STREAMS (3) = 1000
INITIAL_WINDOW_SIZE (4) = 6291456
MAX_HEADER_LIST_SIZE (6) = 262144
WINDOW_UPDATE: 15663105 (15 MB increment)
PRIORITY frames: none separate (uses HEADERS-embedded priority)
Pseudo-header order: :method, :authority, :scheme, :path
The same connection from httpx 0.27 produces:
Akamai HTTP/2 fingerprint:
1:4096;2:1;4:65536|65536|0|a,m,p,s
Decoded:
SETTINGS:
HEADER_TABLE_SIZE (1) = 4096
ENABLE_PUSH (2) = 1
INITIAL_WINDOW_SIZE (4) = 65536
WINDOW_UPDATE: 65536
Pseudo-header order: :authority, :method, :path, :scheme
The differences are obvious. Chrome announces five SETTINGS parameters, httpx announces three. Chrome has push disabled, httpx has it enabled. Chrome uses a 15 MB initial window, httpx uses 64 KB. Chrome’s pseudo-header order is m,a,s,p, httpx is a,m,p,s. Each of these is a distinct flag, and combined they place httpx outside any reasonable browser allowlist.
How Akamai’s HTTP/2 hash is constructed
Akamai’s published format for HTTP/2 fingerprints has four pipe-separated fields:
{settings}|{window_update}|{priorities}|{pseudo_header_order}
- Settings: semicolon-separated
key:valuepairs in the order the client sent them, identifier:value - Window update: the increment of the first WINDOW_UPDATE frame after the connection preface
- Priorities: comma-separated PRIORITY frame summaries, or 0 if none
- Pseudo-header order: comma-separated single letters m/a/s/p for method/authority/scheme/path
This raw string is sometimes hashed (older deployments use MD5 of the string), but most modern Akamai deployments log the raw string and use it directly in rules. Other vendors implement variants:
| vendor | format | basis |
|---|---|---|
| Akamai | pipe-separated, raw string | proprietary |
| Cloudflare | derived hash, internal | proprietary, JA4-aligned |
| DataDome | proprietary fingerprint | uses JA4_h2 from FoxIO |
| FoxIO JA4_H2 | extends JA4 family | open spec |
JA4_H2 is the open-spec equivalent that most modern tools implement. It hashes the SETTINGS, window update, priority frames, and pseudo-header order into a 12-character hash with a readable prefix. See the FoxIO JA4 specification for the exact algorithm.
Library-by-library HTTP/2 fingerprints
What each common Python and Node client emits in mid-2026:
| client | SETTINGS order | window update | pseudo order | risk |
|---|---|---|---|---|
| httpx 0.27 | 1,2,4 | 65536 | a,m,p,s | very high |
| aiohttp 3.10 | 1,4 | 65536 | a,m,p,s | very high |
| curl 8.x | 1,2,3,4 | 65536 | varies by URL | high |
| Node fetch | 1,2,3,4,6 | 1048576 | m,a,s,p | medium |
| Go net/http2 | 1,2,4,6 | 1048576 | varies | high |
| Chrome 124 | 1,2,3,4,6 | 15663105 | m,a,s,p | safe |
| Firefox 124 | 1,4,5 | 12517377 | m,p,a,s | safe |
| curl_cffi (chrome124) | 1,2,3,4,6 | 15663105 | m,a,s,p | safe |
| tls-client (chrome_124) | 1,2,3,4,6 | 15663105 | m,a,s,p | safe |
| Playwright Chromium | 1,2,3,4,6 | 15663105 | m,a,s,p | safe |
The pattern is the same as TLS: stdlib HTTP clients leak a non-browser fingerprint, impersonation libraries match real browsers, and Playwright wins by being a real browser. Where HTTP/2 is harder than TLS is that fewer libraries handle it correctly. Many libraries that claim “HTTP/2 support” only implement the protocol functionally and do not match browser SETTINGS at all.
Bypass approach 1: curl_cffi for HTTP/2 too
curl_cffi handles both TLS and HTTP/2 fingerprinting because the underlying patched libcurl ships with browser-matched HTTP/2 SETTINGS. The same impersonate="chrome124" parameter that fixes your JA4 also fixes your HTTP/2 fingerprint.
from curl_cffi import requests
resp = requests.get(
"https://target.example.com/api/v1/products",
impersonate="chrome124",
proxies={"https": "http://user:pass@proxy.example.com:8080"},
timeout=30,
)
print(resp.status_code)
print("HTTP version:", resp.http_version)
Verify the HTTP/2 fingerprint via tls.peet.ws which also returns the Akamai HTTP/2 string and JA4_H2:
from curl_cffi import requests
resp = requests.get(
"https://tls.peet.ws/api/all",
impersonate="chrome124",
)
data = resp.json()
print("Akamai H2:", data.get("akamai_fingerprint"))
print("JA4_H2:", data.get("ja4_h2"))
print("HTTP/2 sent frames:", data["http2"]["sent_frames"])
The key field is akamai_fingerprint. If it matches the Chrome 124 reference (1:65536;2:0;3:1000;4:6291456;6:262144|15663105|0|m,a,s,p), you are aligned. If it shows fewer SETTINGS or a different pseudo-header order, your library is shipping its own defaults instead of forging Chrome’s.
Bypass approach 2: tls-client with H2 settings
tls-client lets you configure HTTP/2 behavior at a finer grain than curl_cffi. This matters when you need to match a specific browser version that curl_cffi has not added yet, or when you want to mix-and-match TLS and H2 profiles for testing.
import tls_client
session = tls_client.Session(
client_identifier="chrome_124",
h2_settings={
"HEADER_TABLE_SIZE": 65536,
"MAX_CONCURRENT_STREAMS": 1000,
"INITIAL_WINDOW_SIZE": 6291456,
"MAX_HEADER_LIST_SIZE": 262144,
},
h2_settings_order=[
"HEADER_TABLE_SIZE",
"ENABLE_PUSH",
"MAX_CONCURRENT_STREAMS",
"INITIAL_WINDOW_SIZE",
"MAX_HEADER_LIST_SIZE",
],
pseudo_header_order=[":method", ":authority", ":scheme", ":path"],
connection_flow=15663105,
)
resp = session.get(
"https://target.example.com/api",
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
},
)
The h2_settings, h2_settings_order, pseudo_header_order, and connection_flow parameters together define the HTTP/2 fingerprint. Match them all to Chrome 124 as captured above.
Bypass approach 3: real browser via Playwright
Playwright with Chromium matches Chrome’s HTTP/2 fingerprint exactly because it is Chrome. If TLS and HTTP/2 are both being checked at your target, the highest-confidence approach is to drive a real browser:
from playwright.async_api import async_playwright
async def fetch_with_h2_fingerprint(url, proxy_config):
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
proxy=proxy_config,
args=[
"--disable-blink-features=AutomationControlled",
"--disable-features=IsolateOrigins,site-per-process",
],
)
ctx = await browser.new_context(
viewport={"width": 1920, "height": 1080},
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
)
page = await ctx.new_page()
# Use page.request to make API calls that go through Chrome's HTTP stack
api_response = await page.request.get(
f"{url}/api/v1/data",
headers={"Accept": "application/json"},
)
data = await api_response.json()
await browser.close()
return data
The trick here is page.request.get instead of constructing your own HTTP call. By going through page.request, you use Chrome’s actual HTTP/2 stack, which means your fingerprint matches whatever browser version Playwright is using. This is more expensive than curl_cffi (a full Chrome instance per call) but bulletproof against multi-layer fingerprinting.
Common pitfalls when forging HTTP/2
- Settings without window update. Setting six SETTINGS values means nothing if you forget to also send the WINDOW_UPDATE that Chrome sends right after. Detect this by comparing against the reference pattern.
- Wrong pseudo-header order in custom headers. Some libraries let you set headers in arbitrary order but then re-sort them. Verify with a wire capture or with a service like tls.peet.ws.
- HTTP/1.1 fallback. If your TLS ALPN does not advertise h2 or your library defaults to HTTP/1.1, you skip HTTP/2 fingerprinting entirely but flag yourself as “modern client that does not speak HTTP/2 to a modern server,” which is itself anomalous.
- CONTINUATION frames on large headers. Chrome avoids CONTINUATION frames by sizing HEADERS frames generously. If your library splits headers into multiple CONTINUATION frames, that is a flag.
- GOAWAY behavior. Chrome sends GOAWAY before closing connections. Some libraries close abruptly with RST_STREAM, which is anomalous.
For wire-level debugging, use mitmproxy with the --mode reverse flag and inspect raw H2 frames. Or use Wireshark with the HTTP/2 dissector. Both let you see exactly what your library emits versus what Chrome emits side by side.
Comparison: TLS only vs TLS + HTTP/2 fingerprinting impact
Some targets only fingerprint TLS, others stack both. Understanding which is which informs your tooling choice.
| target type | TLS check | HTTP/2 check | minimum tooling |
|---|---|---|---|
| simple WAF | yes | no | curl_cffi or tls-client |
| Cloudflare basic | yes | yes | curl_cffi (covers both) |
| Cloudflare Bot Management | yes | yes | curl_cffi + clean residential |
| DataDome | yes | yes | curl_cffi or Playwright + premium proxy |
| Akamai Bot Manager | yes | yes | Playwright with full browser |
| PerimeterX | yes | yes | Playwright + behavioral simulation |
| Kasada | yes | yes | full Chrome via Playwright + execution of their challenge JS |
The pattern: lighter targets fall to TLS impersonation, enterprise targets need full browser. Plan tooling and budget accordingly. See our breakdown of DataDome vs PerimeterX vs Akamai bot management for vendor-specific tactics.
Production logging for HTTP/2 fingerprints
Add HTTP/2 fingerprint logging alongside TLS so you can correlate failures:
import json
import time
from curl_cffi import requests
def request_with_logging(url, impersonate="chrome124", proxies=None):
start = time.time()
resp = requests.get(url, impersonate=impersonate, proxies=proxies, timeout=30)
latency = (time.time() - start) * 1000
# Sample a verification call every 100 requests to capture fingerprints
fingerprint_data = {}
if hash(url) % 100 == 0:
verify = requests.get(
"https://tls.peet.ws/api/all",
impersonate=impersonate,
proxies=proxies,
)
v = verify.json()
fingerprint_data = {
"ja4": v.get("ja4"),
"ja4_h2": v.get("ja4_h2"),
"akamai_h2": v.get("akamai_fingerprint"),
}
print(json.dumps({
"ts": time.time(),
"url": url,
"status": resp.status_code,
"latency_ms": int(latency),
"impersonate": impersonate,
"fingerprint": fingerprint_data,
}))
return resp
Sampling every 100th request keeps overhead low while still giving you visibility into fingerprint drift. If your Akamai HTTP/2 string changes after a library upgrade, you will see it in the logs.
QUIC and HTTP/3: the next frontier
Chrome and Firefox both negotiate HTTP/3 over QUIC when servers advertise it via the Alt-Svc header. JA4 has a q prefix for QUIC connections, and Akamai has begun publishing HTTP/3 fingerprint formats.
Most scraping libraries do not yet support QUIC fingerprinting in mid-2026. curl_cffi has experimental HTTP/3 support, tls-client does not, and Playwright defaults to HTTP/2 even when HTTP/3 is available. This means a sophisticated target serving HTTP/3 sees:
- Real Chrome connecting via HTTP/3 with a clean QUIC fingerprint
- Your scraper falling back to HTTP/2
That fallback is itself a signal. The fix is one of two paths:
- Disable HTTP/3 advertisement on your scraper if the target allows. Many targets do not require HTTP/3 and only advertise it.
- Use a real headless browser via Playwright if HTTP/3 negotiation matters.
Watch the libraries through 2026 and 2027. Expect curl_cffi and tls-client to both ship reliable HTTP/3 support during 2026, at which point this gap closes.
Sample script: full TLS + HTTP/2 verification
A complete script that verifies your full fingerprint stack before running scraping at scale:
import json
import sys
from curl_cffi import requests
REFERENCE_CHROME_124 = {
"ja4": "t13d1516h2_8daaf6152771_b186095e22b6",
"akamai_fingerprint": "1:65536;2:0;3:1000;4:6291456;6:262144|15663105|0|m,a,s,p",
}
def verify(impersonate="chrome124", proxy=None):
proxies = {"https": proxy} if proxy else None
resp = requests.get(
"https://tls.peet.ws/api/all",
impersonate=impersonate,
proxies=proxies,
timeout=30,
)
data = resp.json()
actual = {
"ja4": data.get("ja4"),
"akamai_fingerprint": data.get("akamai_fingerprint"),
}
mismatches = []
for key, expected in REFERENCE_CHROME_124.items():
if actual[key] != expected:
mismatches.append({
"field": key,
"expected": expected,
"actual": actual[key],
})
return {
"passed": len(mismatches) == 0,
"actual": actual,
"mismatches": mismatches,
}
if __name__ == "__main__":
result = verify()
print(json.dumps(result, indent=2))
sys.exit(0 if result["passed"] else 1)
Run this in CI before deploying scraper changes. If the script exits non-zero, the build fails. This catches the common case where a library upgrade silently changes your fingerprint and you only notice after block rates spike.
For more on aligning all the pieces of a request, see header rotation and TLS profiles for production scrapers.
FAQ
Q: do I need HTTP/2 impersonation if my JA4 already matches Chrome?
For sites running Akamai or any vendor that hashes HTTP/2 SETTINGS, yes. Cloudflare also uses HTTP/2 derived signals. The impersonation libraries handle both at once if you use them correctly, so the cost is zero.
Q: my library claims HTTP/2 support. Is that enough?
No. “HTTP/2 support” in most libraries means “speaks the protocol.” It does not mean “speaks the protocol with the same SETTINGS as Chrome.” Verify with a fingerprint check before assuming.
Q: can I just disable HTTP/2 to skip the check?
You can request HTTP/1.1, but then your TLS ALPN advertises only http/1.1, which is anomalous against modern targets that expect h2. Some scrapers disable H2 against simple targets and turn it on for sophisticated ones. This is a knob in tls-client (http_2_enabled=False).
Q: does using a real browser via Playwright fully solve HTTP/2 fingerprinting?
Yes, as long as you use page.request or let the page itself make the calls (XHR, fetch from page JS). If you spawn external HTTP calls from your Python wrapper, those bypass Chrome’s HTTP stack and revert to whatever Python is using.
Q: how often do browsers change their HTTP/2 fingerprint?
Less often than TLS. Browser HTTP/2 SETTINGS are fairly stable across versions, with changes maybe once or twice a year. The pseudo-header order has been stable in Chrome for years. Window update sizes occasionally adjust. Plan to refresh impersonation profiles quarterly to stay current.
Common pitfalls in production HTTP/2 forging
The first failure mode that bites scrapers in production is partial Chrome impersonation across distinct request paths. A single Python process makes its API calls through curl_cffi (Chrome HTTP/2 fingerprint) but its image downloads through aiohttp (httpx-style HTTP/2 fingerprint). The target sees the same IP completing a JS challenge with a clean Chrome fingerprint, then immediately requesting /static/img/logo.png with 1:4096;2:1;4:65536|65536|0|a,m,p,s from the same source port range. That mismatch flips the bot score within seconds. The fix is library-uniformity: route every outbound request through the same impersonation client, even for assets you do not strictly need.
The second pitfall is connection coalescing that you did not plan for. Chrome opens one HTTP/2 connection per origin and reuses it for hundreds of streams. If your scraper opens a new TLS handshake for every request, the target sees a flurry of identical INITIAL_WINDOW_SIZE=6291456 connection presets in seconds. Real Chrome would have produced one preset per minute. Akamai’s HTTP/2 module specifically scores “handshake-per-request rate” alongside the fingerprint hash. Configure curl_cffi sessions with multiplex=True and reuse the same Session object across all calls to a host. Verify with ss -tn state established '( dport = :443 )' that you have one socket per target host, not dozens.
The third pitfall is HEADERS frame size mismatch. Chrome sends HEADERS frames padded to the nearest 256-byte boundary in some configurations, and Akamai logs the unpadded versus padded ratio. Most impersonation libraries either always pad or never pad, producing a binary signal that diverges from Chrome’s “sometimes pads” pattern. The current workaround is to accept this as a known minor deviation and rely on perfect SETTINGS+window+pseudo-header alignment to outweigh it. There is no library in mid-2026 that perfectly matches Chrome’s adaptive padding behavior.
Real-world example: Akamai HTTP/2 score recovery
A retail scraper running curl_cffi 0.7.4 against an Akamai-protected catalog API started seeing 60 percent block rates after a target migrated from Akamai Bot Manager Premier to Akamai Account Protector. The TLS JA4 was correct (t13d1516h2_8daaf6152771_b186095e22b6), the akamai_fingerprint string matched Chrome 124, and the User-Agent rotated correctly. The actual cause was the connection_flow=15663105 parameter being sent on the first connection but not on subsequent reconnects after idle timeout. Akamai treated the idle-timeout reconnect as a new client with default flow control, then compared it against the expected first-flow value from the prior session and flagged the mismatch.
from curl_cffi import requests
session = requests.Session(impersonate="chrome124")
session.curl.setopt("HTTP2_STREAM_WINDOW", 6291456)
session.curl.setopt("HTTP2_CONNECTION_WINDOW", 15663105)
session.headers.update({"Connection": "keep-alive"})
# Force the session to never let the connection idle out
for url in target_urls:
resp = session.get(url, timeout=30)
if resp.status_code == 403:
# Don't recreate the session; recycle proxy instead
session.proxies = next_proxy()
Block rate dropped from 60 percent to 4 percent within an hour. Lesson: HTTP/2 fingerprinting is stateful across the connection lifetime, not just the initial handshake. Reuse sessions and verify flow control persists across reconnects.
Wrapping up
HTTP/2 fingerprinting is the second layer that catches scrapers who fixed TLS and stopped there. Cloudflare, Akamai, and DataDome all check both. The good news is that the same libraries that fix TLS also fix HTTP/2 if you use them correctly, so the fix is one library, not two. Verify your fingerprints before deploying, log them in production for drift detection, and migrate your tooling as Chrome and Firefox roll forward through 2026 and 2027. Browse the anti-detect-browsers category on DRT for more on the layered defenses scrapers face today.