← All posts

Fingerprinting the HTTP Request — Beyond User-Agent, JA4, and QUIC

Austin Kauffman ·
#security#fingerprinting#deep-dive

User-Agent is trivial to fake. Source IPs rotate. JA4 names the TLS library. HTTP/2 and QUIC fingerprints describe the transport frame. None of them capture how that client actually talks HTTP — which headers it sends, in what order, with what content types, at what path depth.

That gap is why we built the request fingerprint (req_fp). It is a shape of the HTTP exchange: method, path structure, headers, params, cookies, language, auth — not the values themselves. Scanners, libraries, and browsers leave different shapes even when they all copy a Chrome UA string.

A typical log value looks like:

l_ge_20_01_nil_nil_0_0000_07_ahuxxxx_0000_n_n_nil_n_200_20482

Seventeen underscore-separated fields. coreX also splits them into http.request.fingerprint.* so you can write rules on one piece instead of the whole string.

How the 17 fields group

Request line — who is calling what

#Log fieldCoreX fieldWhat it is
1req_fp_path_b62(in full http.request.fingerprint)Path encoded as base62. Same path → same token; useful for “this exact URL shape,” not human reading.
2req_fp_methodGET / POST / … (ge, po, …).
3req_fp_http_verhttp.request.version_numeric11 = HTTP/1.1, 20 = HTTP/2, 30 = HTTP/3.
4req_fp_path_depthhttp.request.fingerprint.path_depthNumber of / in the path.

Policy use: Browsers on a marketing site almost always GET a shallow path over HTTP/2. A scanner POSTs to a deep API endpoint like /api/v1/batch/items at depth 4 over HTTP/1.1. Path-traversal and “secret file” dumps show up as unusual depth or as a path_b62 you have never seen on that host.

Do not put the full fingerprint in a request-phase block list if you care about path: field 1 changes per URL, so a probe for /config.json and a probe for /.env are different full strings. Use subfields (header_list, header_count, …) so one rule covers the whole campaign.

Parameters — what they sent, not the secrets

#Log fieldCoreX fieldWhat it is
5req_fp_param_keyshttp.request.fingerprint.param_keysFirst letter of each query/body param name, sorted. nil if none.
6req_fp_param_typeshttp.request.fingerprint.param_typesType of each value: i int, s string, o object, l list, e empty, …
7req_fp_param_lenshttp.request.fingerprint.param_lensLength of each value (5-10).
8req_fp_ctypehttp.request.fingerprint.content_typeFirst 4 letters of Content-Type subtype (json, xwww, 0000 if none).

Policy use: A login POST is usually param_keys like pu (password, username), types ss, modest lengths, ctype xwww or json. An exploit often sends an object/list where a string belongs, a 10k-char field, or JSON on an endpoint that never takes a body. SQLi and log-injection scanners show up as “wrong type / huge length” more reliably than as a specific payload string.

http.request.fingerprint.body_depth (JSON nesting) is the same idea for bodies: {a:{b:{c:…}}} used in parser bombs.

Headers — the best “who is this client” signal

#Log fieldCoreX fieldWhat it is
9req_fp_hdr_counthttp.request.fingerprint.header_countHow many headers.
10req_fp_hdr_listhttp.request.fingerprint.header_listSorted first letters of header names (capped). Example: ahuxxxx.

This is the field used for scanner_hdr_lists. A real Chrome request has Accept, Accept-Encoding, Accept-Language, Cookie, Sec-Fetch-*, Priority, etc. → a long list like aaacccccchpruuxxxx. curl, Python requests, and cheap scanners send Accept + Host + User-Agent and little else → ahuxxxx and count 7.

Policy use:

  • Exact header_list is a stable client ID. One credential-stuffing campaign we tracked was 100% ahuxxxx; a second, separate campaign was 100% aaaccccchruxxxx. Put those in a pattern list and you block the campaign even when IP and UA change.
  • header_count alone is too blunt. A well-known vendor’s crawler sat at 8; a header_count < 10 challenge is too aggressive for that reason. Combine count with list, language, or JA4.
  • Libraries rarely send Accept-Language or Cookie; browsers almost always do.

Client context — browser vs script

#Log fieldCoreX fieldWhat it is
11req_fp_accept_lang(also http.request.geo_lang_mismatch)First 4 alpha chars of Accept-Language (enus, zhcn, 0000 if missing).
12req_fp_auth_typehttp.request.fingerprint.auth_typen none, b basic, t bearer, d digest, o other.
13–14req_fp_cookie / cookie_fieldsCookie present? Initials of cookie names (not values).
15req_fp_referern none, s same-origin, x cross-origin.

Policy use:

  • Missing language (0000) + no cookie + no referer is the default scanner triple. A large search-engine’s crawler was the opposite tell: zhcn on a mobile UA from that provider’s ASN — internally consistent, where a fake enus on the same ASN would be the mismatch.
  • geo_lang_mismatch (language vs GeoIP country) catches VPN/scanner farms that forget to align headers with the exit IP.
  • Cookie names (sid, session, __cf) fingerprint your app’s real users without storing session tokens. A “browser” with no cookies on a page that always sets them is a bot.
  • Auth type: GET /admin with basic from the internet is a different event than a bearer token on /api.

Response — only after the app answers

#Log fieldCoreX fieldWhat it is
16req_fp_statushttp.response.fingerprint.statusStatus code.
17req_fp_body_byteshttp.response.fingerprint.body_bytesBody size.

These are response-phase. You cannot block with them on the way in; you can use them for allowlists of known-good “this client + this endpoint + this size” combinations, or to spot scanners that always get 200 HTML error pages of a fixed length.

The full http.request.fingerprint string is also response-phase ({partial}_{status}_{body_bytes}).

Why this is better than UA / IP / JA4 / h2-QUIC fingerprinting

SignalEasy to fake?Stable across a campaign?What it actually identifies
User-AgentYesUntil they rotate itMarketing string
Source IP / ASNSomewhatNo (they rotate)Network, not client
JA4Hard (need a different TLS stack)Yes for that binaryTLS library
HTTP/2 fingerprint (Akamai h2)Hard (need a different h2 stack)Yes for that binaryHTTP/2 frame settings/order
QUIC fingerprintHard (need a different QUIC impl)Yes for that binaryQUIC transport
header_list + header_count + lang/cookieHave to reimplement a browserYesHTTP client implementation
param_types / param_lensHave to change the exploitYes per exploit familyAttack shape

The credential-stuffing campaign above proved the point: truncated Chrome UA, rotating paths, but every request was hdr_list=ahuxxxx, hdr_count=7, accept_lang=0000, cookie absent. One list entry covers hundreds of URLs.

JA4 and req_fp complement each other. One botnet’s JA4 used TLS 1.3 with HTTP/1.1 ALPN (…h1_…) — browsers on TLS 1.3 speak h2. That JA4 plus param_types on a batch API POST is stronger than either signal alone.

How to write policies with it

Prefer lists of subfields, not one giant fingerprint.

http.request.fingerprint.header_list in $pattern:scanner_hdr_lists
http.request.fingerprint.header_count < 8
and http.request.fingerprint.auth_type = "n"
http.request.fingerprint.path_depth > 8
http.request.fingerprint.param_types contains "o"
and http.request.method = "GET"

Good patterns

  • Header list / JA4 lists you grow over time.
  • Combine two cheap signals: low header count and no Accept-Language and not a known good bot.
  • Param shape on a specific path: login must look like ss + form/json, not a 50-key object.
  • Path depth or body depth as a ceiling (path_depth > 8, body_depth > 10) — already used in default risk rules.

Bad patterns

  • header_count < 10 with no other condition (legitimate crawlers, some APIs, HTTP/2 with few headers).
  • Matching the full req_fp string for a scanner (path_b62 and status/bytes change every hit).
  • Using generic Chrome JA4 as if it were a client ID (too many real browsers share it).
  • Response fields in a request-phase block rule (they are not set yet).

Mental model: JA4 = “which TLS library.” h2/QUIC fingerprint = “which transport stack.” req_fp = “how that client builds an HTTP request.” Security lists on header_list, param_types, and auth_type stay small and stay valid when the attacker changes IP, UA, or even rotates TLS and transport fingerprints.