JA3 fingerprinting was a game-changer when Salesforce published it in 2017. For the first time, defenders could identify clients by their TLS handshake alone. But in 2026, relying solely on JA3 is like checking only someone's shoes to verify their identity - necessary but nowhere near sufficient.

Modern anti-bot systems have moved far beyond TLS fingerprinting. Here's what they're looking at now.

Quick JA3 Recap

JA3 creates a fingerprint from five fields in the TLS ClientHello:

  • TLS version
  • Cipher suites
  • Extensions
  • Elliptic curves (supported groups)
  • Elliptic curve point formats

These are concatenated and MD5-hashed to produce a 32-character fingerprint. The problem? Too many clients now spoof JA3 successfully. Libraries like curl_cffi impersonate Chrome's JA3 perfectly. So defenders adapted.

HTTP/2 Fingerprinting: The New Frontier

When a client establishes an HTTP/2 connection, it sends a SETTINGS frame containing connection parameters. These parameters are a goldmine for fingerprinting because most HTTP libraries use different defaults than browsers.

The key SETTINGS parameters:

SETTINGS_HEADER_TABLE_SIZE      (0x1)  → Chrome: 65536,   Python: 4096
SETTINGS_ENABLE_PUSH            (0x2)  → Chrome: 0,       Python: 0
SETTINGS_MAX_CONCURRENT_STREAMS (0x3)  → Chrome: 1000,    Python: varies
SETTINGS_INITIAL_WINDOW_SIZE    (0x4)  → Chrome: 6291456, Python: 65535
SETTINGS_MAX_FRAME_SIZE         (0x5)  → Chrome: 16384,   Python: 16384
SETTINGS_MAX_HEADER_LIST_SIZE   (0x6)  → Chrome: 262144,  Python: absent

Notice the window size difference - Chrome uses 6MB while most Python libraries default to 64KB. That single value is enough to distinguish a real browser from a script.

WINDOW_UPDATE After SETTINGS

Immediately after SETTINGS, clients send a WINDOW_UPDATE frame to adjust the connection-level flow control window. Chrome sends a specific value here:

WINDOW_UPDATE: increment = 15663105

This isn't random - it's 15728640 - 65535, bringing the connection window to Chrome's preferred size. Most HTTP libraries either don't send this frame or send a different value.

SETTINGS Order Matters

It's not just the values - the order of parameters in the SETTINGS frame is a signal. Chrome always sends them in a specific order:

Chrome:  HEADER_TABLE_SIZE, ENABLE_PUSH, MAX_CONCURRENT, INITIAL_WINDOW, MAX_FRAME, MAX_HEADER_LIST
Firefox: HEADER_TABLE_SIZE, MAX_CONCURRENT, INITIAL_WINDOW, MAX_FRAME, MAX_HEADER_LIST
Safari:  MAX_CONCURRENT, INITIAL_WINDOW, MAX_FRAME, MAX_HEADER_LIST, ENABLE_PUSH

Each browser has a distinct ordering. Python's httpx and aiohttp use yet another order.

Header Order Fingerprinting

This is Akamai's signature technique. When a browser sends an HTTP request, the headers have a consistent order:

Chrome order:
:method: GET
:authority: example.com
:scheme: https
:path: /
accept: text/html
accept-encoding: gzip, deflate, br
accept-language: en-US
cookie: ...
sec-ch-ua: ...
sec-fetch-dest: document
sec-fetch-mode: navigate
sec-fetch-site: none
user-agent: Mozilla/5.0 ...

Python requests sends headers in a completely different order - and critically, it uses lowercase header names while also missing pseudo-headers (:method, :path, etc.) or placing them incorrectly.

Pseudo-Header Ordering

HTTP/2 pseudo-headers (:method, :authority, :scheme, :path) must come before regular headers. But their internal order varies by browser:

Chrome:  :method, :authority, :scheme, :path
Firefox: :method, :path, :authority, :scheme
Safari:  :method, :scheme, :path, :authority

This is a subtle but reliable fingerprint. Most HTTP libraries follow a different order than any browser.

Priority Frames (PRIORITY / PRIORITY_UPDATE)

HTTP/2 allows clients to express how they'd like resources prioritized. Chrome sends PRIORITY frames with specific stream dependencies and weights that form a tree structure. The exact tree shape is a fingerprint.

HTTP/3 introduced the PRIORITY_UPDATE frame with a different scheme. Each browser constructs different priority trees:

Chrome:  Uses "urgency" and "incremental" parameters
Firefox: Uses stream dependencies with weight values
Safari:  Minimal priority signaling

Most HTTP libraries either skip priority signaling entirely or use simple default values - both of which are obvious tells.

Akamai's HTTP/2 Fingerprint Format

Akamai developed a comprehensive HTTP/2 fingerprint format that combines multiple signals:

S[settings]|WU[window_update]|P[priorities]|PS[pseudo_header_order]

For example, Chrome's fingerprint looks something like:

1:65536;2:0;3:1000;4:6291456;5:16384;6:262144|15663105|0|m,a,s,p

Where m,a,s,p represents the pseudo-header order (method, authority, scheme, path).

This single string captures enough information to distinguish Chrome from Firefox from Safari from Python - even if the TLS fingerprint is perfectly spoofed.

How to Match Browser HTTP/2 Signatures

If you need your client to pass HTTP/2 fingerprinting, you need to control these low-level parameters. Standard Python libraries don't expose this level of control.

Options:

1. curl_cffi (Easiest)

curl_cffi wraps curl-impersonate, which patches libcurl to match real browser signatures at both TLS and HTTP/2 levels:

from curl_cffi import requests

response = requests.get(
    "https://example.com",
    impersonate="chrome"
)

This handles SETTINGS values, WINDOW_UPDATE, header order, and pseudo-header ordering automatically.

2. Custom HTTP/2 Framing

For full control, you can use hyper-h2 to manually construct HTTP/2 frames:

import h2.connection
import h2.config

config = h2.config.H2Configuration(client_side=True)
conn = h2.connection.H2Connection(config=config)

# Set Chrome-like SETTINGS
conn.initiate_connection()
conn.update_settings({
    h2.settings.SettingsFrame.HEADER_TABLE_SIZE: 65536,
    h2.settings.SettingsFrame.ENABLE_PUSH: 0,
    h2.settings.SettingsFrame.MAX_CONCURRENT_STREAMS: 1000,
    h2.settings.SettingsFrame.INITIAL_WINDOW_SIZE: 6291456,
    h2.settings.SettingsFrame.MAX_FRAME_SIZE: 16384,
    h2.settings.SettingsFrame.MAX_HEADER_LIST_SIZE: 262144,
})

This gives you control but requires managing the entire HTTP/2 connection manually.

The Full Detection Stack (2026)

Here's the complete fingerprinting hierarchy that modern anti-bot systems use:

  1. IP reputation - Datacenter vs. residential, ASN history, geo-consistency
  2. TLS fingerprint (JA3/JA4) - ClientHello parameters and ordering
  3. HTTP/2 fingerprint - SETTINGS, WINDOW_UPDATE, priorities, header order
  4. HTTP header fingerprint - Header names, values, ordering, pseudo-headers
  5. JavaScript fingerprint - Canvas, WebGL, AudioContext, navigator properties
  6. Behavioral fingerprint - Mouse, scroll, timing, interaction patterns

Each layer reduces the anonymity set. Spoofing JA3 alone gets you past layer 2 but fails at layer 3. You need to match at every level.

Key Takeaways

  • JA3 spoofing is table stakes - everyone does it now, so defenders look deeper
  • HTTP/2 SETTINGS, WINDOW_UPDATE, and header ordering are powerful fingerprints
  • Pseudo-header order (:method, :path, etc.) differs between browsers and is trivial to check
  • Akamai's HTTP/2 fingerprint format combines multiple signals into one string
  • curl_cffi handles most of this automatically by impersonating real browser stacks
  • The fingerprinting stack is additive - you must match at every layer, not just one

The arms race has moved from the TLS layer to the HTTP/2 layer. The principles are the same - match the real browser exactly, at every protocol level.


This is an educational overview for security research and authorized testing. Always ensure you have permission before testing any system.