JA3 and JA4 fingerprints have been showing up more and more often in discussions about bot mitigation and client identification. Amazon CloudFront exposes TLS-based JA4 as a CloudFront-generated header, but it does not provide the HTTP-request-based JA4H fingerprint.
That made me wonder: “Why not implement it ourselves with CloudFront Functions?” So I did. There are a few gotchas and design considerations you need to be aware of, and I’ll cover those as well.
In this article, I’ll first demonstrate the limitation through hands-on testing, then work around it with the CloudFront-Viewer-Header-Order header and show how to calculate a JA4H-equivalent fingerprint entirely within CloudFront Functions.
What Is JA4?
JA4+ is a suite of network fingerprints published by FoxIO and positioned as a successor to JA3. The best-known member is JA4, which is calculated from the TLS ClientHello. It generates a short string that is nearly unique to a particular client implementation, based on factors such as the TLS version, whether SNI is present, cipher suites, and extensions.
The key idea behind JA4 is that the same implementation tends to produce the same value. Chrome, curl, and Python’s requests, for example, each have distinct characteristics in their TLS stacks and therefore produce different JA4 fingerprints.
A User-Agent string can be spoofed freely, but accurately reproducing every detail of a TLS handshake is considerably harder. This makes it possible to detect inconsistencies such as a client claiming to be Chrome in its User-Agent while presenting a JA4 fingerprint associated with Python.
In addition to TLS-based JA4, the JA4+ suite includes several other variants, such as JA4H, which is calculated from HTTP request headers, and JA4S, which fingerprints the server side of a TLS connection.
Amazon CloudFront, JA4, and CloudFront KVS
CloudFront announced support for JA3 in 2022 and JA4 in October 2024. By adding CloudFront-Viewer-JA4-Fingerprint to an origin request policy, you can receive the precomputed JA4 value as a request header.
This header is also available to a CloudFront Function running on the viewer-request event. Combined with CloudFront KeyValueStore (KVS), it lets you operate a blocklist of known malicious fingerprints entirely at the edge.
import cf from 'cloudfront';
const kvs = cf.kvs();
async function handler(event) {
const request = event.request;
const ja4 = request.headers['cloudfront-viewer-ja4-fingerprint'];
if (ja4) {
try {
const action = await kvs.get(ja4.value); // Example: "block"
if (action === 'block') {
return { statusCode: 403, statusDescription: 'Forbidden' };
}
} catch (e) {
// get() throws when the key does not exist.
// An unregistered fingerprint is not known to be malicious, so do nothing.
}
}
return request;
}
KVS can be updated immediately through the console or API. For example, you can analyze suspicious requests in existing logs, register their fingerprint values in KVS, and begin blocking them without redeploying the function.
What Is JA4H?
JA4H is a fingerprint calculated from the HTTP request itself. It consists of four parts joined by underscores:
JA4H = <a>_<b>_<c>_<d>
Example: ge11cn05enus_974ebe531c03_000000000000_000000000000
- JA4H_a: The first two characters of the HTTP method + two digits representing the HTTP version + whether a Cookie is present (
c/n) + whether a Referer is present (r/n) + a two-digit header count excluding Cookie and Referer + the first four characters derived from Accept-Language - JA4H_b: The first 12 hexadecimal characters of a SHA-256 hash over the comma-separated header names in their original transmission order, excluding Cookie and Referer
- JA4H_c: A hash of the sorted, comma-separated Cookie names
- JA4H_d: A hash of the sorted, comma-separated Cookie
name=valuepairs
The most important part is JA4H_b. Browsers and HTTP clients have implementation-specific habits in the order in which they send headers, so that order becomes useful identifying information.
Conversely, calculating JA4H accurately requires both the original header order and header information that has not been rewritten along the way.
Implementing JA4H in CloudFront Functions
Recovering header order and original casing with CloudFront-Viewer-Header-Order
It turns out that the CloudFront Functions event object alone is not enough to calculate JA4H.
As the official documentation states, “Header names are converted to ASCII-lowercase in the event object.” In addition, headers is an object keyed by header name, so it does not expose the order in which headers were sent. I confirmed this experimentally: changing the transmission order did not change the order visible in the event object.
This is where the CloudFront request header CloudFront-Viewer-Header-Order comes in.
Contains the viewer's header names in the order requested, separated by a colon.
CloudFront-Viewer-Header-Order contains the header names sent by the viewer, in the order CloudFront received them, separated by colons. The value is not affected by the event object’s lowercase normalization. In my tests, it contained the information required for JA4H_b:
$ curl -s --http1.1 ... -H "X-Test-ZZZ: 1" -H "x-test-aaa: 2"
→ Host:User-Agent:Accept:X-Test-ZZZ:x-test-aaa
$ curl -s --http1.1 ... -H "x-test-aaa: 2" -H "X-Test-ZZZ: 1"
→ Host:User-Agent:Accept:x-test-aaa:X-Test-ZZZ
$ curl -s --http1.1 ... -H "x-dup: 1" -H "X-Test-ZZZ: 1" -H "x-dup: 2"
→ Host:User-Agent:Accept:x-dup:X-Test-ZZZ:x-dup
When the request is sent over HTTP/2, the value becomes fully lowercase, such as host:user-agent:accept:.... That is expected because HTTP/2 requires header field names to be lowercase.
CloudFront-Viewer-Header-Order is not included in the managed AllViewerAndCloudFrontHeaders-2022-06 origin request policy, so you need to create a custom policy and add it explicitly. You should also add CloudFront-Viewer-Http-Version, because the HTTP version required for JA4H_a is not included in the event object.
aws cloudfront create-origin-request-policy --origin-request-policy-config '{
"Name": "ja4h-policy",
"HeadersConfig": {
"HeaderBehavior": "allViewerAndWhitelistCloudFront",
"Headers": {"Quantity": 2, "Items": [
"CloudFront-Viewer-Header-Order",
"CloudFront-Viewer-Http-Version"]}
},
"CookiesConfig": {"CookieBehavior": "all"},
"QueryStringsConfig": {"QueryStringBehavior": "all"}
}'
There are two caveats.
First, the header value is truncated at 7,680 characters, so the resulting JA4H value may be inaccurate for requests containing an extreme number of headers.
Second, the preservation of header-name casing is behavior I observed experimentally; it is not explicitly guaranteed in the documentation. Keep that assumption in mind when relying on it.
Implementation
CloudFront Functions runtime 2.0 includes the crypto module, so SHA-256 can be calculated directly inside the function.
// JA4H for CloudFront Functions (runtime: cloudfront-js-2.0)
// Requires CloudFront-Viewer-Header-Order and
// CloudFront-Viewer-Http-Version in the origin request policy.
var crypto = require('crypto');
var ZERO12 = '000000000000';
function sha12(s) {
return crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
}
function handler(event) {
var req = event.request;
var h = req.headers;
var order = h['cloudfront-viewer-header-order']
? h['cloudfront-viewer-header-order'].value : '';
var httpVer = h['cloudfront-viewer-http-version']
? h['cloudfront-viewer-http-version'].value : '1.1';
// Exclude Cookie and Referer while preserving transmission order,
// casing, and duplicate header names.
var filtered = (order ? order.split(':') : []).filter(function (n) {
var l = n.toLowerCase();
return l !== 'cookie' && l !== 'referer';
});
// ---- JA4H_a ----
var method = req.method.toLowerCase().slice(0, 2); // GET -> ge
var ver = httpVer.replace('.', '').slice(0, 2); // 1.1 -> 11, 2.0 -> 20
if (ver.length === 1) ver += '0';
var cookieKeys = Object.keys(req.cookies);
var hasCookie = cookieKeys.length > 0 ? 'c' : 'n';
var hasReferer = h.referer ? 'r' : 'n';
var count = Math.min(filtered.length, 99);
var countStr = (count < 10 ? '0' : '') + count;
var lang = '0000';
if (h['accept-language']) {
var l = h['accept-language'].value
.split(',')[0].split(';')[0].toLowerCase().replace(/-/g, '');
lang = (l + '0000').slice(0, 4);
}
var a = method + ver + hasCookie + hasReferer + countStr + lang;
// ---- JA4H_b: hash header names in transmission order ----
var b = filtered.length ? sha12(filtered.join(',')) : ZERO12;
// ---- JA4H_c / JA4H_d: sort and hash Cookie names / name=value pairs ----
var cnames = [], cpairs = [];
cookieKeys.forEach(function (n) {
var c = req.cookies[n];
if (c.multiValue) {
c.multiValue.forEach(function (m) {
cnames.push(n); cpairs.push(n + '=' + m.value);
});
} else {
cnames.push(n); cpairs.push(n + '=' + c.value);
}
});
cnames.sort(); cpairs.sort();
var c12 = cnames.length ? sha12(cnames.join(',')) : ZERO12;
var d12 = cpairs.length ? sha12(cpairs.join(',')) : ZERO12;
req.headers['x-ja4h'] = { value: a + '_' + b + '_' + c12 + '_' + d12 };
return req;
}
Duplicate Cookies can be retrieved from the multiValue array. Because JA4H_c and JA4H_d hash sorted values, their original order is not needed.
Comparing the Output with the Reference Implementation
Some details of JA4H_a—such as exactly which headers are included in the count and how Accept-Language is normalized—are described only briefly in the FoxIO specification. That makes differences between implementations relatively easy to introduce.
Before deploying this in production, compare the output with FoxIO’s reference implementation. The repository includes a Rust CLI named ja4 that analyzes pcap files and emits the various JA4+ fingerprints. It uses tshark internally, and prebuilt binaries are available from the repository’s Releases page.
The important point is that JA4H requires plaintext HTTP headers. An HTTPS packet capture cannot be analyzed as-is because the headers are encrypted.
A convenient workaround is to send an equivalent request over plaintext HTTP to a local server and capture it there. curl sends the same header names when invoked with the same command-line options. The Host value will differ, but that does not affect JA4H_b because it hashes header names rather than values.
# Install tshark and obtain the ja4 binary (macOS example)
brew install --cask wireshark
sudo ln -s /Applications/Wireshark.app/Contents/MacOS/tshark /usr/local/bin/tshark
# Start a local plaintext HTTP server and capture traffic
python3 -m http.server 8000 &
sudo tcpdump -i lo0 -w /tmp/ja4h.pcap 'port 8000' &
# Send a request with the same method, HTTP version, headers, and Cookies
# as the request sent to CloudFront
curl --http1.1 http://localhost:8000/ \
-H "Accept-Language: ja" -H "X-Test-ZZZ: 1" -b "session=abc"
# Extract JA4H and compare it with x-ja4h from CloudFront Functions
./ja4 /tmp/ja4h.pcap
When the results differ, it helps to add temporary debug output to the function that exposes JA4H_a and b_input, the string before hashing. That makes it much easier to identify which component is responsible for the mismatch.
To validate an HTTPS request without recreating it over plaintext HTTP, another option is to record session keys with SSLKEYLOGFILE, decrypt the capture in Wireshark, and inspect it with the JA4 plugin.
A quick licensing note: TLS-based JA4 is licensed under the BSD 3-Clause License, while JA4+ methods including JA4H use the FoxIO License 1.1. Internal use for protecting your own services is permitted, but offering the functionality as part of a commercial product requires an OEM license.
Reducing False Positives with a Challenge Page
Immediately blocking every request that matches a JA4 or JA4H fingerprint can be risky.
Security products, VPNs, and proxies may reorder headers. When that order differs from normal browser behavior, a legitimate user’s fingerprint may no longer match the fingerprint normally produced by their browser, leading to false positives or false negatives.
JA4H also intentionally groups clients using the same implementation. As a result, many legitimate users running the same browser version may share the same fingerprint. A mistake in blocklist management could therefore block every one of them at once.
Instead of returning a 403 immediately, I recommend redirecting suspicious JA4H fingerprints to a challenge page. A human user can complete the verification step, and requests carrying proof of successful completion can then pass through normally.
The action for each fingerprint can be stored in KVS, with the viewer-request function deciding how to handle it:
import cf from 'cloudfront';
const kvs = cf.kvs();
async function handler(event) {
const request = event.request;
const ja4h = computeJa4h(event); // JA4H calculation logic
// Validate an expiring, HMAC-signed clearance Cookie.
if (hasValidClearance(request, ja4h)) {
request.headers['x-ja4h'] = { value: ja4h };
return request;
}
let action = 'allow';
try {
action = await kvs.get(ja4h);
} catch (e) { /* Unregistered = allow */ }
if (action === 'challenge') {
return {
statusCode: 302,
headers: {
'location': { value: '/challenge?return=' + encodeURIComponent(request.uri) },
'cache-control': { value: 'no-store' }
}
};
}
if (action === 'block') {
return { statusCode: 403, statusDescription: 'Forbidden' };
}
request.headers['x-ja4h'] = { value: ja4h };
return request;
}
The contents of /challenge depend on your requirements. The simplest option is a JavaScript challenge that merely confirms the client can execute JavaScript, because many basic bots cannot.
For stronger verification, validate Cloudflare Turnstile or reCAPTCHA at the origin and issue an HMAC-signed clearance Cookie after successful completion. Runtime 2.0’s crypto module also supports HMAC, so subsequent Cookie validation can remain entirely at the edge.
You might be wondering whether AWS WAF’s Challenge or CAPTCHA actions could handle this instead. On CloudFront, however, WAF evaluation runs before CloudFront Functions. That means a WAF rule cannot use an x-ja4h header added by the function during the same request.
When routing decisions must be based on JA4H, the function itself also needs to perform the challenge redirect. As an aside, AWS WAF has a HeaderOrder match component, so WAF alone can still perform checks based only on header order.
A practical rollout would look like this:
- Observe JA4H values for all requests by recording
x-ja4hin the origin access logs. - Identify fingerprints that appear disproportionately in attack traffic.
- Register those fingerprints in KVS with the
challengeaction. - Promote only fingerprints that are clearly malicious to
block.
Summary
-
CloudFront-Viewer-Header-Orderlets you recover header order, casing, and duplicate names with high fidelity. The event object alone cannot do this because it lowercases header names and loses their transmission order. - Combined with
CloudFront-Viewer-Http-Version, it allows a JA4H-equivalent fingerprint to be calculated inline inside CloudFront Functions. - Because legitimate users can share the same JA4H value, a staged response using KVS and a challenge page is safer than blocking immediately.
CloudFront may eventually expose JA4H as another generated viewer header. Until then, this is one way to implement comparable fingerprinting yourself.
Give it a try—and, as always with fingerprints, observe first and block carefully.
Top comments (0)