Technical 11 min read

HTTP/2 Fingerprinting: Capture Client Frame Signatures

Capture HTTP/2 fingerprints from SETTINGS, WINDOW_UPDATE, PRIORITY, and pseudo-header order using a local Python listener you control.

FT
FineData Engineering · Editorial Policy
| | Updated August 10, 2026

HTTP/2 Fingerprinting: Capture Client Frame Signatures

TLS JA3/JA4 hashes stop at the ClientHello. The next cleartext-shaped signal many edge systems record is the client’s HTTP/2 preface and the first control frames that follow — often before a meaningful response body exists. Those frames advertise flow-control defaults, stream priority habits, and the order of :method / :authority / :scheme / :path in a way that varies by stack more than by URL.

This piece mirrors the measurement approach in JA3/JA4 TLS Fingerprints Across HTTP Clients: you run a listener on a host you control, point your own clients at it, and compare the printed fingerprints. For the TLS layer underneath, see TLS Fingerprinting Explained. For how frame-level checks sit next to headers, JS challenges, and automation signals, see anti-bot detection layers and headless browser detection signals.

What Arrives Before the Useful GET

After ALPN selects h2, the client MUST send the connection preface:

PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n

Immediately after that magic string comes an HTTP/2 SETTINGS frame (type 0x4). Servers that fingerprint HTTP/2 typically hash or stringify a small prefix of what follows:

  1. SETTINGS — parameter identifiers and values, in wire order. Common IDs include header table size (1), enable push (2), max concurrent streams (3), initial window size (4), max frame size (5), and max header list size (6). Two clients can advertise the same values in a different order and already diverge.
  2. WINDOW_UPDATE (type 0x8) — often a connection-level bump on stream 0 right after SETTINGS. The increment is part of many published fingerprint strings.
  3. PRIORITY (type 0x2) and/or priority fields on HEADERS — legacy dependency / weight signals. RFC 9113 deprecates the old priority scheme, but stacks still differ in whether they emit anything here.
  4. Pseudo-header order on the first request — HPACK-decoded order of :method, :authority, :scheme, :path (browsers and libraries disagree). Regular headers after that are a separate, noisier signal; most compact fingerprints stick to the four pseudo-fields.
  5. HPACK dynamics — indexed vs literal encodings and dynamic-table updates. Full HPACK transcripts are heavy; pedagogical fingerprints usually stop at pseudo-header order from the first HEADERS block.

None of this requires reading a third-party site’s HTML. The signal is on the bytes your client sends to your TLS terminator.

A Neutral String Format

Operators publish several encoding dialects. A format commonly attributed to a large CDN operator concatenates four sections with | (SETTINGS itself still uses ; between id:value pairs):

SETTINGS|WINDOW_UPDATE|PRIORITY|PSEUDO_HEADER_ORDER

Where:

  • SETTINGSid:value pairs joined by ;, preserving arrival order (example shape: 1:65536;2:0;4:6291456;6:262144).
  • WINDOW_UPDATE — decimal increment from the first connection-level WINDOW_UPDATE, or empty if none arrived before you stop reading.
  • PRIORITY — a compact token for the first priority signal (0 if absent; otherwise dependency / weight fields as your parser defines).
  • PSEUDO_HEADER_ORDER — abbreviated letters in decode order, for example m,a,s,p for :method, :authority, :scheme, :path.

The educational goal is a stable, comparable string for clients you own, not a claim that every vendor uses identical punctuation. Re-run the listener after library upgrades; nghttp2, Go’s HTTP/2 stack, and browser builds change defaults over time.

How Clients Tend to Differ

Structural tendencies you will usually see when each client speaks HTTP/2 with stock settings. Exact integers belong in your terminal output — not in a copied table of unverified hashes:

Client familyTypical H2 stackWhat often stands out
curl (HTTP/2 build)nghttp2Distinct SETTINGS set/order; preface + control frames arrive promptly
Python httpx / h2h2 library defaultsLibrary SETTINGS that rarely match browser builds
Go net/httpgolang.org/x/net/http2Recognizable Go window and settings habits
Node (undici / http2)Node’s HTTP/2 bindingNode-shaped settings; pseudo-header order may differ from Chrome
Chrome / Firefox / SafariBrowser stacksBrowser-tuned SETTINGS, richer priority/header behavior, m,a,s,p-class pseudo orders that still vary by engine

Treat the table as a hypothesis checklist. Anti-bot style scoring often cares about:

  1. Lack of HTTP/2 while the User-Agent claims a modern browser (ALPN/h2 mismatch — already visible in JA4’s a suffix).
  2. Library SETTINGS paired with a browser User-Agent.
  3. Pseudo-header order that browsers of that UA family do not emit.
  4. Fleet monoculture — many IPs sharing one SETTINGS+window string.

Capture those facts on localhost; do not invent production denylist values.

Methodology: Terminate TLS, Then Read Frames

Unlike the JA3 listener (which only needs the first TLS record), HTTP/2 fingerprinting needs a completed handshake with ALPN h2, a server SETTINGS flight so polite clients continue, and a short read loop over the first application-data frames.

Goals:

  1. Bind TLS on a port you control with ALPN advertising h2.
  2. Accept one connection; complete the handshake.
  3. Read the client preface; parse frames until SETTINGS, optional WINDOW_UPDATE / PRIORITY, and the first HEADERS (or a timeout).
  4. Print a four-part fingerprint string plus a human-readable dump.
  5. Drive curl, httpx, Go, Node, and a browser at that port.

Use a machine you administer. Certificate warnings are expected with a one-day self-signed cert.

Runnable Python Listener (stdlib)

Save as h2_fingerprint_listener.py. Dependencies: Python 3.10+ and openssl on PATH (used once to mint a throwaway localhost cert). Frame parsing is pure stdlib — no pip install required for SETTINGS / WINDOW_UPDATE / PRIORITY / a best-effort HPACK decode of static/indexed pseudo-headers.

If a client sends only literal encodings your minimal HPACK path cannot name, the script still prints SETTINGS and WINDOW_UPDATE; install optional h2 only if you want a fuller header decoder later.

#!/usr/bin/env python3
"""
Local HTTP/2 fingerprint listener (stdlib).

Completes a TLS handshake with ALPN h2, reads the client connection
preface and early frames, prints a lab fingerprint string:

  SETTINGS|WINDOW_UPDATE|PRIORITY|PSEUDO_HEADER_ORDER

Usage:
  python3 h2_fingerprint_listener.py --host 127.0.0.1 --port 8443

Then aim an HTTP/2 client at https://127.0.0.1:8443/ (accept the
self-signed cert warning, or use -k / verify=False).
"""

from __future__ import annotations

import argparse
import ssl
import socket
import struct
import subprocess
import sys
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple

PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"

FRAME_DATA = 0x0
FRAME_HEADERS = 0x1
FRAME_PRIORITY = 0x2
FRAME_RST_STREAM = 0x3
FRAME_SETTINGS = 0x4
FRAME_PUSH_PROMISE = 0x5
FRAME_PING = 0x6
FRAME_GOAWAY = 0x7
FRAME_WINDOW_UPDATE = 0x8
FRAME_CONTINUATION = 0x9

FLAG_ACK = 0x1
FLAG_END_STREAM = 0x1
FLAG_END_HEADERS = 0x4
FLAG_PADDED = 0x8
FLAG_PRIORITY = 0x20

# HPACK static table (RFC 7541 Appendix A) — subset used for pseudo-headers.
STATIC_TABLE: Dict[int, Tuple[str, str]] = {
    1: (":authority", ""),
    2: (":method", "GET"),
    3: (":method", "POST"),
    4: (":path", "/"),
    5: (":path", "/index.html"),
    6: (":scheme", "http"),
    7: (":scheme", "https"),
}

PSEUDO_LETTER = {
    ":method": "m",
    ":authority": "a",
    ":scheme": "s",
    ":path": "p",
}


@dataclass
class H2Fingerprint:
    settings: List[Tuple[int, int]] = field(default_factory=list)
    window_update: Optional[int] = None
    priority_token: str = "0"
    pseudo_order: List[str] = field(default_factory=list)

    def as_string(self) -> str:
        settings_part = ";".join(f"{i}:{v}" for i, v in self.settings)
        wu = "" if self.window_update is None else str(self.window_update)
        hdr = ",".join(self.pseudo_order) if self.pseudo_order else ""
        return f"{settings_part}|{wu}|{self.priority_token}|{hdr}"


def ensure_localhost_cert(dir_path: Path) -> Tuple[Path, Path]:
    cert = dir_path / "cert.pem"
    key = dir_path / "key.pem"
    if cert.exists() and key.exists():
        return cert, key
    subprocess.run(
        [
            "openssl",
            "req",
            "-x509",
            "-newkey",
            "rsa:2048",
            "-keyout",
            str(key),
            "-out",
            str(cert),
            "-days",
            "1",
            "-nodes",
            "-subj",
            "/CN=localhost",
        ],
        check=True,
        capture_output=True,
    )
    return cert, key


def build_frame(ftype: int, flags: int, stream_id: int, payload: bytes) -> bytes:
    header = struct.pack("!I", len(payload))[1:]  # 24-bit length
    header += struct.pack("!BBI", ftype, flags, stream_id & 0x7FFFFFFF)
    return header + payload


def server_settings_frame() -> bytes:
    # Minimal server SETTINGS so clients proceed; values are ours, not fingerprinted.
    pairs = [(3, 100), (4, 65535)]
    payload = b"".join(struct.pack("!HI", k, v) for k, v in pairs)
    return build_frame(FRAME_SETTINGS, 0, 0, payload)


def settings_ack() -> bytes:
    return build_frame(FRAME_SETTINGS, FLAG_ACK, 0, b"")


def recv_exact(conn: socket.socket, n: int) -> bytes:
    buf = b""
    while len(buf) < n:
        chunk = conn.recv(n - len(buf))
        if not chunk:
            raise ConnectionError("peer closed")
        buf += chunk
    return buf


def parse_frame(conn: socket.socket) -> Tuple[int, int, int, bytes]:
    header = recv_exact(conn, 9)
    length = int.from_bytes(header[0:3], "big")
    ftype = header[3]
    flags = header[4]
    stream_id = struct.unpack("!I", header[5:9])[0] & 0x7FFFFFFF
    payload = recv_exact(conn, length) if length else b""
    return ftype, flags, stream_id, payload


def decode_settings(payload: bytes) -> List[Tuple[int, int]]:
    out: List[Tuple[int, int]] = []
    for i in range(0, len(payload), 6):
        if i + 6 > len(payload):
            break
        sid, val = struct.unpack_from("!HI", payload, i)
        out.append((sid, val))
    return out


def decode_priority_payload(payload: bytes) -> str:
    if len(payload) < 5:
        return "short"
    dep = struct.unpack("!I", payload[0:4])[0]
    exclusive = (dep >> 31) & 1
    stream_dep = dep & 0x7FFFFFFF
    weight = payload[4] + 1
    return f"e={exclusive},d={stream_dep},w={weight}"


def _decode_int(data: bytes, bit_off: int, prefix: int) -> Tuple[int, int]:
    byte_i = bit_off // 8
    mask = (1 << prefix) - 1
    val = data[byte_i] & mask
    bit_off = (byte_i + 1) * 8
    if val < mask:
        return val, bit_off
    m = 0
    while True:
        b = data[bit_off // 8]
        bit_off += 8
        val += (b & 0x7F) << m
        m += 7
        if not (b & 0x80):
            break
    return val, bit_off


def _decode_string(data: bytes, bit_off: int) -> Tuple[bytes, int]:
    huffman = (data[bit_off // 8] >> 7) & 1
    strlen, bit_off = _decode_int(data, bit_off, 7)
    byte_i = bit_off // 8
    raw = bytes(data[byte_i : byte_i + strlen])
    bit_off += strlen * 8
    return (b"" if huffman else raw), bit_off


def hpack_pseudo_order(block: bytes) -> List[str]:
    """Best-effort order from indexed / indexed-name literals (no Huffman names)."""
    order: List[str] = []
    bit_off = 0
    end_bits = len(block) * 8
    while bit_off + 8 <= end_bits:
        b = block[bit_off // 8]
        if b & 0x80:
            idx, bit_off = _decode_int(block, bit_off, 7)
            name = STATIC_TABLE.get(idx, ("", ""))[0]
        elif (b & 0xE0) == 0x20:
            _size, bit_off = _decode_int(block, bit_off, 5)
            continue
        elif (b & 0xC0) == 0x40:
            if b & 0x3F:
                idx, bit_off = _decode_int(block, bit_off, 6)
                name = STATIC_TABLE.get(idx, ("", ""))[0]
            else:
                bit_off += 8
                nb, bit_off = _decode_string(block, bit_off)
                name = nb.decode("latin1", "replace")
            _vb, bit_off = _decode_string(block, bit_off)
        elif (b & 0xF0) in (0x00, 0x10):
            if b & 0x0F:
                idx, bit_off = _decode_int(block, bit_off, 4)
                name = STATIC_TABLE.get(idx, ("", ""))[0]
            else:
                bit_off += 8
                nb, bit_off = _decode_string(block, bit_off)
                name = nb.decode("latin1", "replace")
            _vb, bit_off = _decode_string(block, bit_off)
        else:
            break
        letter = PSEUDO_LETTER.get(name)
        if letter and letter not in order:
            order.append(letter)
    return order


def consume_header_block(flags: int, payload: bytes) -> bytes:
    view = memoryview(payload)
    off = 0
    if flags & FLAG_PADDED:
        pad = view[0]
        off = 1
    else:
        pad = 0
    if flags & FLAG_PRIORITY:
        if off + 5 > len(view):
            return b""
        off += 5
    end = len(view) - pad
    if end < off:
        return b""
    return bytes(view[off:end])


def read_fingerprint(conn: socket.socket, timeout: float = 5.0) -> H2Fingerprint:
    conn.settimeout(timeout)
    preface = recv_exact(conn, len(PREFACE))
    if preface != PREFACE:
        raise ValueError(f"bad client preface: {preface[:24]!r}")

    # Encourage the peer to continue (SETTINGS + ACK of their SETTINGS later).
    conn.sendall(server_settings_frame())

    fp = H2Fingerprint()
    saw_headers = False
    # Bound the number of frames so idle clients cannot hang the lab tool.
    for _ in range(32):
        try:
            ftype, flags, stream_id, payload = parse_frame(conn)
        except (socket.timeout, ConnectionError):
            break

        if ftype == FRAME_SETTINGS and not (flags & FLAG_ACK):
            if not fp.settings:
                fp.settings = decode_settings(payload)
            conn.sendall(settings_ack())
        elif ftype == FRAME_WINDOW_UPDATE and stream_id == 0 and fp.window_update is None:
            if len(payload) >= 4:
                fp.window_update = struct.unpack("!I", payload[:4])[0] & 0x7FFFFFFF
        elif ftype == FRAME_PRIORITY and fp.priority_token == "0":
            fp.priority_token = decode_priority_payload(payload)
        elif ftype == FRAME_HEADERS:
            if flags & FLAG_PRIORITY and fp.priority_token == "0":
                # Priority fields sit at the start of HEADERS payload after optional pad length.
                tmp = payload
                off = 1 if (flags & FLAG_PADDED) else 0
                if off + 5 <= len(tmp):
                    fp.priority_token = decode_priority_payload(bytes(tmp[off : off + 5]))
            block = consume_header_block(flags, payload)
            if flags & FLAG_END_HEADERS:
                fp.pseudo_order = hpack_pseudo_order(block)
                saw_headers = True
                break
            # CONTINUATION handling: concatenate until END_HEADERS.
            while not (flags & FLAG_END_HEADERS):
                ftype, flags, _sid, cont = parse_frame(conn)
                if ftype != FRAME_CONTINUATION:
                    break
                block += cont
            fp.pseudo_order = hpack_pseudo_order(block)
            saw_headers = True
            break
        elif ftype in (FRAME_GOAWAY, FRAME_RST_STREAM):
            break

    if not saw_headers and not fp.settings:
        raise ValueError("no SETTINGS/HEADERS observed — is the client speaking h2?")
    return fp


def serve_once(host: str, port: int, cert: Path, key: Path) -> None:
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ctx.load_cert_chain(certfile=str(cert), keyfile=str(key))
    ctx.set_alpn_protocols(["h2"])

    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        sock.bind((host, port))
        sock.listen(1)
        print(f"listening on https://{host}:{port}/ (ALPN h2) — connect an HTTP/2 client", flush=True)
        raw, addr = sock.accept()
        with raw:
            print(f"connection from {addr[0]}:{addr[1]}", flush=True)
            with ctx.wrap_socket(raw, server_side=True) as tls:
                negotiated = tls.selected_alpn_protocol()
                print(f"ALPN {negotiated!r}", flush=True)
                if negotiated != "h2":
                    raise RuntimeError("peer did not negotiate h2 — force HTTP/2 on the client")
                fp = read_fingerprint(tls)
                print("H2_FINGERPRINT", fp.as_string())
                print("SETTINGS", fp.settings)
                print("WINDOW_UPDATE", fp.window_update)
                print("PRIORITY", fp.priority_token)
                print("PSEUDO_HEADER_ORDER", fp.pseudo_order)


def main(argv: Optional[List[str]] = None) -> int:
    p = argparse.ArgumentParser(description="Capture one HTTP/2 fingerprint")
    p.add_argument("--host", default="127.0.0.1")
    p.add_argument("--port", type=int, default=8443)
    p.add_argument("--cert-dir", default="")
    args = p.parse_args(argv)
    tmp_owned = None
    try:
        if args.cert_dir:
            cert_dir = Path(args.cert_dir)
            cert_dir.mkdir(parents=True, exist_ok=True)
        else:
            tmp_owned = tempfile.TemporaryDirectory(prefix="h2fp-")
            cert_dir = Path(tmp_owned.name)
        cert, key = ensure_localhost_cert(cert_dir)
        serve_once(args.host, args.port, cert, key)
    except Exception as exc:  # noqa: BLE001 — CLI tool
        print(f"error: {exc}", file=sys.stderr)
        return 1
    finally:
        if tmp_owned is not None:
            tmp_owned.cleanup()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Drive Clients Against the Listener

Terminal A:

python3 h2_fingerprint_listener.py --host 127.0.0.1 --port 8443

Terminal B — restart the listener between trials:

# curl with HTTP/2
curl -sk --http2 https://127.0.0.1:8443/ -o /dev/null

# Python httpx (HTTP/2) — needs: pip install 'httpx[http2]'
python3 -c "import httpx; c=httpx.Client(http2=True, verify=False, timeout=5); c.get('https://127.0.0.1:8443/'); c.close()"

# Go net/http (HTTP/2 enabled by default over TLS)
cat > /tmp/h2probe.go <<'EOF'
package main
import (
  "crypto/tls"
  "net/http"
  "time"
)
func main() {
  tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
  c := &http.Client{Transport: tr, Timeout: 5 * time.Second}
  c.Get("https://127.0.0.1:8443/")
}
EOF
go run /tmp/h2probe.go

# Node undici / http2
node -e "
const http2=require('http2');
const c=http2.connect('https://127.0.0.1:8443',{rejectUnauthorized:false});
c.on('error',()=>{});
const r=c.request({':method':'GET',':path':'/'});
r.on('response',()=>c.close());
r.end();
"

For a real browser: open https://127.0.0.1:8443/, accept the certificate warning, and confirm the listener printed ALPN 'h2' before the fingerprint line.

Example Output Shape

Your numbers will differ by OS and library version. A local curl --http2 trial against this listener produced a shape like:

listening on https://127.0.0.1:8443/ (ALPN h2) — connect an HTTP/2 client
connection from 127.0.0.1:54321
ALPN 'h2'
H2_FINGERPRINT 3:100;4:10485760;2:0|1048510465|0|m,s,a,p
SETTINGS [(3, 100), (4, 10485760), (2, 0)]
WINDOW_UPDATE 1048510465
PRIORITY 0
PSEUDO_HEADER_ORDER ['m', 's', 'a', 'p']

Note the pseudo-header order m,s,a,p — common for nghttp2-backed curl, and different from the m,a,s,p order many browsers emit. Re-run on your machine after upgrades. If PSEUDO_HEADER_ORDER is empty, the client likely used HPACK literals or Huffman codes beyond the stdlib decoder — SETTINGS and WINDOW_UPDATE remain valid comparison keys, or extend the decoder with the optional h2 package:

pip install h2

Use that library’s event-driven connection object if you need production-grade header decoding; keep the fingerprint string format the same so lab notes stay comparable.

Stacking With TLS Fingerprints

Defenders rarely score HTTP/2 alone. A useful lab notebook column set is:

TrialJA3 / JA4H2 fingerprintUser-Agent
stock curl(from TLS listener)(from this listener)curl/…
httpx+h2python-httpx/…
browserMozilla/5.0 …

Mismatches are the story: browser UA with library JA4, or browser JA4 with library SETTINGS / pseudo-header order. Automation that upgrades TLS fidelity without aligning HTTP/2 still leaves a seam. Headless and UI-automation signals beyond the wire format are covered separately in headless browser detection signals.

Operational Checklist for Your Lab

  1. Run the listener only on hosts you administer.
  2. Record H2 fingerprints next to library versions for every client you ship.
  3. Keep SETTINGS order intact — sorting destroys the signal.
  4. Re-measure after OpenSSL, nghttp2, Go, Node, or browser upgrades.
  5. Correlate with JA3/JA4 from the TLS client comparison script; either layer alone is incomplete.

Optional: Richer Header Decode With h2

If stdlib HPACK leaves PSEUDO_HEADER_ORDER empty, feed the same TLS bytes into the h2 package’s H2Connection.receive_data and keep the four-part string formatter. Many HTTP/2 client libraries expose SETTINGS knobs — changing them changes the fingerprint by design, the same way swapping TLS backends changes JA3. That is a property of configurable stacks, not advice aimed at any named defensive product.

Summary

HTTP/2 fingerprinting reads the preface, SETTINGS (order and values), WINDOW_UPDATE, priority signals, and pseudo-header order — often before any interesting HTML arrives. Library defaults for curl/nghttp2, Python h2/httpx, Go, Node, and browsers diverge on those fields; measure the gap by terminating TLS locally. Pair results with JA3/JA4 comparison and TLS Fingerprinting Explained when monitoring your own egress clients.

#http2 #fingerprinting #tls #http-clients #security #python

Related Articles