A JA4 fingerprint is a client "fingerprint" extracted from the TLS handshake (ClientHello).
It is tied to the TLS library / client stack that makes the connection: every client built on the same stack shares it,
and it does not change when the IP / country / VPN / User-Agent is switched.
However, whether JA4 can be captured depends on "who receives the TLS handshake first (where TLS is terminated)".
This page lists the feasibility for each representative setup pattern, and, further down, how to get the client JA4 from a load balancer or CDN that terminates TLS in front of unmask (GCP / Cloudflare / AWS and others).
Conclusion: it depends on where TLS is terminated
[Client] ──TLS handshake──▶ [ only whoever saw the ClientHello can compute JA4 ]
Computing JA4 requires the raw ClientHello byte sequence. A process that only sees
the plaintext HTTP after TLS decryption cannot reconstruct JA4 afterward.
JA4 feasibility by setup pattern
Computing JA4 needs the raw ClientHello bytes, so feasibility is decided solely by
where TLS is terminated (= who sees the client's ClientHello first) and
how unmask is deployed (nginx native module / gateway container). The backend app is irrelevant.
● = node where unmask is installed ★ = TLS termination (where the ClientHello is seen)
Setup (client → …)
JA4
unmask config
1. Front LB (co-located with unmask) + backend app — unmask terminates TLS
★●LB nginx (native) → httpd
yes
none (plugin computes it)
★●gateway container → httpd (any HTTP server)
yes
none (the module inside computes it)
2. App-direct — unmask terminates TLS
★●nginx (native)
yes
none
3. Front CDN / LB terminates TLS and forwards the client JA4 in a header (see the notes below) + backend app
4. Front CDN / LB terminates TLS and forwards the client JA4 in a header + unmask direct
★CDN → ●nginx (native)
yes
select under “trusted LB / CDN”
native = nginx + unmask-plugin-nginx. The C module parses the ClientHello directly and computes JA4 (fastest, no subrequest, runs even on old distros like CentOS 6). Use this if your site is served by nginx and you want JA4.
gateway container = the unmask.sh/unmask image: the official nginx image with the unmask module, plus the unmask daemon, in one container. It terminates TLS in front of any HTTP server (Apache, Node, another nginx, …) and computes JA4 the same way as native, so nothing changes on the server behind it. See Running unmask in containers and the install guide.
Apache has no unmask module: for JA4 on an Apache site, put the gateway container in front of it (the recommended setup for Apache).
Rows 3–4 need a front layer that forwards the client JA4 in a request header: GCP HTTPS LB through a custom request header (X-Client-JA4); Cloudflare through its own cf-ja4 header, once that is enabled (plan-dependent); AWS CloudFront / ALB only if your AWS setup can deliver the JA4 in a header. Without such a header, these rows get no JA4.
In rows 3–4 the step is the same either way: pick your front CDN / LB under “trusted LB / CDN”. unmask then accepts the forwarded JA4 only from that CDN / LB's address ranges and ignores it from anywhere else; the header name comes with the preset (X-Client-JA4, or cf-ja4 for Cloudflare). No toggle and no manual wiring in nginx. See LB / CDN setup below.
HTTP/3 (QUIC) — the q prefix
The first letter of a JA4 fingerprint names the transport the ClientHello arrived over:
t = TLS over TCP, q = QUIC. The nginx plugin
computes JA4 for HTTP/3 the same way it does for TCP — when nginx is built with
--with-http_v3_module and listens on a listen … quic port,
the QUIC ClientHello passes through the same TLS-library callback the module already
hooks, so no extra configuration is needed. Verified against both TLS backends nginx
HTTP/3 builds commonly use: the OpenSSL 3.5+ native QUIC API and the quictls patch set.
The same client presents a different string over HTTP/3 than over TCP — the transport letter, the ALPN and the hello contents all change. When writing a custom JA4 rule for a site that serves HTTP/3, match both forms.
unmask's cookie rebinding already treats an HTTP/2 ↔ HTTP/3 switch by the same visitor as normal — it does not count as a fingerprint mismatch.
Behind a front CDN / LB that terminates HTTP/3 and forwards JA4, the forwarded value arrives with a q prefix; the trusted-LB gate passes it through unchanged.
What happens if JA4 cannot be captured?
Even with an empty JA4, unmask still works. Bot detection continues with the following signals:
User-Agent classification — identifies known bots via a crawler / bot UA list of 600+ patterns
Official IP ranges — registers official ranges (Googlebot / Bingbot etc.) on the bypass list to avoid ranking accidents. Vendors that publish ranges are verified by IP, not UA, so a spoofed UA is caught (crawlers without a range pass on UA)
rate-limit — escalates to a challenge when the per-IP × path threshold is exceeded
honeypot — bans clients that hit registered trap paths (e.g. CMS paths your site doesn't run)
behavioral CAPTCHA — a human-likeness score across 5 axes: mouseTrail / scroll / keyboard etc.
What you lose without JA4 is detection accuracy for stealth bots that impersonate legitimate browsers.
A bot that cleanly fakes both its UA and IP can only be spotted by its TLS quirks (JA4).
Conversely, as long as you can capture JA4, you can track the same tool through IP rotation and UA spoofing.
Summary — which setup to choose
Site served by nginx → nginx + unmask-plugin-nginx (native mode). nginx must be terminating TLS
Apache or any other HTTP server → the gateway container in front of it. It terminates TLS and computes JA4 itself
Have a front CDN / LB that terminates TLS → have it forward the client JA4 in a header → LB / CDN setup below
If SSL termination happens on a Load Balancer (GCP / AWS / Cloudflare etc.), the backend nginx never sees
the client's TLS handshake: it receives plain HTTP or the LB's own TLS connection, so a JA4
computed there is empty or the LB's own TLS fingerprint. To get the real client's JA4 you need to configure the
LB to extract the JA4 from the client TLS handshake, put it in a header, and forward it to the backend.
Below is how to set that up on common LBs / CDNs. It is the same for unmask's native mode and the
gateway container.
1. The big picture
[Client] ──TLS──▶ [LB] ──HTTP + X-Client-JA4──▶ [nginx + unmask]
↑ ↑
extract JA4 here read it from the header
The LB / CDN's job:
① Compute the JA4 fingerprint from the client TLS handshake
② Put the value in a header of the request forwarded to the backend: X-Client-JA4 by convention (Cloudflare uses its own cf-ja4, which unmask's Cloudflare preset reads as is)
③ Any value of that header the client sent must be overridden at the LB stage (spoofing prevention)
There are two places to configure:
§2 CDN / LB side — put the client JA4 into a request header (configured per LB)
§3 unmask side — declare “trust that LB” in the admin UI. The default is no LB trusted (secure default), so it is explicitly opt-in
2. CDN / LB-side setup
Pick the LB / CDN you use and set it up to put the client JA4 into a request header. This section is the same for native mode and the gateway container.
GCP HTTPS Load Balancer
On GCP's HTTPS LB, if you embed the {client_ja4_fingerprint} template variable into a template
using the custom request header feature, the LB computes the JA4 and expands it into the header.
GCP's custom header feature always overrides the client-sent value, so there is no spoofing concern.
Step 1: Edit the backend service (Console)
From the Google Cloud Console:
Left menu → Network Services → Load balancing
Click the relevant LB → Edit
Backend configuration → the relevant backend service → edit with the ✏️ pencil icon
Enter X-Client-JA4:{client_ja4_fingerprint} as the header
Save → Update to finish
Step 2: Do the same thing with the gcloud CLI
# add a custom header to an existing backend service in one line (note this is an overwrite that does not preserve existing headers).
gcloud compute backend-services update YOUR_BACKEND_SERVICE \
--global \
--custom-request-header="X-Client-JA4:{client_ja4_fingerprint}"
※ Specifying --custom-request-header multiple times keeps only the last one (if you want to
combine it with existing ones, specify all headers at once). Verify: gcloud compute backend-services describe YOUR_BACKEND_SERVICE --global.
Step 3: Trust it in unmask, then verify
In the unmask admin, select GCP HTTPS Load Balancer under trusted LB / CDN (§3), then check the result as in §4.
Spoofing prevention: GCP's --custom-request-headerunconditionally overrides the client-sent value, so an X-Client-JA4 a client sends itself is replaced at the LB. One sent straight to nginx, bypassing the LB, is not used by unmask, because it does not come from the LB's address ranges. For extra defense, it is recommended to use a firewall (GCP VPC firewall rule) so nginx only accepts traffic via the LB (35.191.0.0/16, 130.211.0.0/22, 35.227.0.0/16 etc.).
Cloudflare
Cloudflare can send the visitor's JA4 fingerprint to the origin in its own cf-ja4 request header,
once that header is enabled for your zone (Step 1; availability depends on your Cloudflare plan). unmask reads
cf-ja4 as is: there is nothing to rename and nothing to add to nginx.
Step 1: Enable cf-ja4 with Managed Transforms
Cloudflare dashboard → select the relevant zone
Rules → Transform Rules → Managed Transforms
Turn on the managed request header that adds the visitor's JA4 as cf-ja4
cf-ja4 is then added to requests to the origin
Step 2: Select the Cloudflare preset in unmask
In the unmask admin, Settings → Network → trusted LB / CDN, check Cloudflare and save (§3).
unmask then reads cf-ja4 only on requests that come from Cloudflare's address ranges, and does not use it
from any other address. Nothing is added to the nginx server block.
Note: whether cf-ja4 is available depends on your Cloudflare plan.
Check Cloudflare's official docs for the current plan requirements.
Spoofing prevention: the preset already ignores cf-ja4 from addresses outside Cloudflare's ranges. For defense in depth, also add a firewall (cloud firewall / security group / iptables) so nginx only accepts traffic from Cloudflare IPs (cloudflare.com/ips).
AWS ALB / CloudFront
Behind CloudFront or an ALB, nginx sees AWS's connection, not the visitor's, so the client JA4 has to arrive in a
request header, and unmask trusts that header by the address that connects to nginx. Whether your
AWS setup can put the viewer's JA4 into a header depends on AWS's current features and your configuration; check
AWS's documentation. unmask's side of each option:
Option A: CloudFront or the ALB delivers the JA4 in a header
CloudFront connects to your origin: select the AWS CloudFront preset under trusted LB / CDN (§3).
It trusts CloudFront's origin-facing address ranges and reads the X-Client-JA4 header, so the JA4 must reach
your origin under that name, overriding any value the client sent. If it arrives under another header name, add a
custom LB row with those ranges and that header name instead of the preset.
An ALB connects to your origin (whichever layer added the header): there is no ALB preset, because an ALB
reaches your instances from addresses in your own VPC. Add a custom LB row with the ALB's subnets and the
header name. A header the ALB forwards is then trusted as the ALB's, so make sure only the layer that sets it can reach the ALB.
Option B: Terminate TLS on the instance
With no TLS-terminating load balancer in front, nginx sees the client TLS handshake itself: run nginx with the
unmask module, or the gateway container, on the EC2 instance with
the site's certificate (the gateway container can obtain one from Let's Encrypt). JA4 then needs no header.
However, you lose ALB features (sticky sessions, WAF integration).
Without a JA4 header: unmask works without JA4; the UA filter, rate limit, honeypot and CAPTCHA keep running. What drops is detection of stealth bots that imitate a real browser (see What happens if JA4 cannot be captured?).
Other LBs / Reverse proxies
Any TLS-terminating front layer
The recipe is the same as the LBs above: terminate TLS at the front
layer, compute the client JA4 there, and forward it to the backend in
a request header, X-Client-JA4 by convention (overriding any client-sent value).
Add the front layer's source CIDRs and that header name as a custom LB row in the
admin Settings → Network tab so unmask trusts the header. Any reverse
proxy that can expose the client TLS handshake's JA4 can drive unmask
this way.
An nginx you run yourself
If the front layer is an nginx you run, it needs no JA4 header: switching it to unmask's native mode is the easiest
(an official module is provided), and the module computes JA4 from the ClientHello itself. Or use the
gateway container as the front layer; it does the same.
For install steps, see the install guide.
3. unmask-side setup
Once §2 has the front layer (LB / CDN) sending the JA4 header, the unmask side just decides which source to trust, in one place. The action is the same for native mode and the gateway container (admin → Settings → Network).
Under “trusted LB / CDN”, check the LB / CDN you use (GCP HTTPS LB / Cloudflare / AWS CloudFront, …)
For anything not listed (your own internal LB / ALB etc.), add its source CIDR + JA4 header under custom LB
On save, unmask adopts the JA4 header only when it comes from that source (the header name comes with the preset: X-Client-JA4, or cf-ja4 for Cloudflare); a direct visitor's spoofed header is dropped. Then sudo nginx -s reload (the gateway container reloads itself). Nothing is added to the nginx server block for JA4. You're done.
Under the hood (no operator action needed): native — the plugin computes JA4 from the ClientHello (works even with no front layer), and the rendered http.inc takes the trusted LB's header instead when one arrives; gateway container — the same, inside the container. No separate toggle.
4. Verify it works (admin dashboard)
After the reload, open the target site in a private browser window, so your browser holds no unmask pass cookie yet
In the unmask admin, open Bot hunt and find your own IP. The JA4 column shows the fingerprint unmask used for that request (it starts with t, or q over HTTP/3). unmask records a visit when it is challenged, which by default includes a first visit without the cookie
Or, on the unmask host: sudo unmask stats -kind ja4 -since 1h lists the fingerprints recorded in the last hour
Reading the JA4 column:
Different fingerprints for different browsers / devices → the client JA4 is arriving ✓
- (empty) → no JA4 reached unmask: the LB is not sending the header, or the LB / CDN is not selected in §3
NOT_TRUSTED → a JA4 header arrived from an address outside the trusted ranges: check which LB / CDN is selected, and its CIDRs
The same fingerprint on every request → it is the LB's own TLS connection to nginx, not the client's: the header is not being used
(none) in the verdict column, or in the Stats page's “JA4 verdict distribution” card, is not a failure: it means the fingerprint matched no verdict rule, which is what an ordinary browser does. It also covers requests with no JA4 at all, so judge by the JA4 column itself.
Native mode, request by request: http.inc defines a log format named unmask that records the JA4 unmask used and why (diag=ok; untrusted_lb_header = a JA4 header from outside the trusted ranges; trusted_no_header = a trusted LB sent none). Point an extra access log at it for a while, in the same block as your existing access_log line:
access_log /var/log/nginx/unmask-ja4.log unmask; # then reload nginx
sudo tail -f /var/log/nginx/unmask-ja4.log # each line: ... ja4=... v=... a=... diag=...