Akamai Bot Manager internals Measured notes on the sensor, the payload and the edge

Transport fingerprints

Akamai grades the connection before it grades the payload. A perfect sensor_data over a stock Node socket is a bot with a good payload, and the decision was already made.

The numbers below were measured by taking the ClientHello off the wire on a local TLS server, completing the handshake, answering the HTTP/2 request and reporting the fingerprints. Chrome 151 headless and Node's http2 client, against the same server:

ChromeNode
cipher suites16, one GREASE52, no GREASE
extensions18, two GREASE12, no GREASE
ALPN offeredh2, http/1.1h2
JA375a6d12708aa584ed06f0e4da0c1fcf2983846581fdb62fafdb21d2282592c57
JA4t13d1516h2_8daaf6152771_806a8c22fdeat13d5212h2_b262b3658495_8e6e362c5eac
HTTP/21:65536;2:0;4:6291456;6:262144|15663105|1:1:0:256,3:1:0:220|m,a,s,p|00|0|p,m,a,s

Every column differs, and each difference classifies on its own.

JA3 is not stable for Chrome, JA4 is

The Chrome JA3 above differs from the value the same browser produced on the same machine a day earlier, while the JA4 cipher hash 8daaf6152771 is unchanged and is the value Chrome is known to produce.

GREASE values and extension order are randomised per connection by design, so a JA3 taken raw is a moving target unless GREASE is stripped first. Treat a JA3 mismatch across two of your own runs as normal, and prefer JA4 or an explicit field-by-field comparison when you need something to hold still.

What closes the gap

Reproducing the payload is the smaller half. A client posting sensor_data without reproducing the handshake needs one of:

The second is what the browserless client here uses, wrapping a Chrome-profile TLS library with extension order randomisation left on:

const session = new Session({
  clientIdentifier: ClientIdentifier[profile],
  timeout,
  insecureSkipVerify: false,
  randomTlsExtensionOrder: true,
});

How it is detected: measured directly against a production API that judges the payload in a request header, plain Node fetch is refused with a header the edge had accepted from a browser moments earlier, while the same header over the impersonating stack is served. Nothing else about the request changed. Header order and casing sit on top of all this and are the part that gets attention first and buys the least.

Caveats

These are one Chrome build on macOS, first request on a fresh connection. Chrome's later requests on the same connection carry different priority information, so a client compared mid-connection will not match a first-request reference.