unmask

docs

JA4 acquisition, LB / CDN setup, supported distros, FAQ.

Getting the client JA4 from your LB

If SSL termination happens on a Load Balancer (GCP / AWS / Cloudflare etc.), the TLS handshake the backend nginx sees is the one between the LB and nginx, so computing a JA4 from it yields 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.

This page shows exactly how to set that up on each LB. It is common to both unmask's native mode and forward-auth mode.

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:

There are two places to configure:

2. CDN / LB-side setup

Pick the LB / CDN you use and set it up to put the client JA4 into the X-Client-JA4 header. This section is common to both native and forward-auth modes.

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:

  1. Left menu โ†’ Network Services โ†’ Load balancing
  2. Click the relevant LB โ†’ Edit
  3. Backend configuration โ†’ the relevant backend service โ†’ edit with the โœ๏ธ pencil icon
  4. Expand Advanced configurations โ†’ + ADD HEADER under Custom request headers
  5. Enter X-Client-JA4:{client_ja4_fingerprint} as the header
  6. Save โ†’ Update to finish
[ Screenshot: GCP Console > Load balancing > Edit backend > Custom request headers ]
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: Verify it works

On the host where nginx + unmask is running:

curl -sI https://your-domain/unmask/healthz \
    | grep -i x-client-ja4    # the header the LB forwards. visible at the backend
# or: in the unmask admin stats tab โ†’ the JA4 verdict distribution becomes non-empty
Spoofing prevention: GCP's --custom-request-header unconditionally overrides the client-sent value. Even if a client throws X-Client-JA4 directly at nginx, it is erased once it passes through the LB. 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

By default Cloudflare forwards a cf-ja4 request header to the origin (JA4 fingerprint). On the nginx side, you just rename cf-ja4 to X-Client-JA4 and unmask can read it.

Step 1: Enable cf-ja4 with Managed Transforms
  1. Cloudflare dashboard โ†’ select the relevant zone
  2. Rules โ†’ Transform Rules โ†’ Managed Transforms
  3. Turn ON the Add visitor location headers or JA4 fingerprint family of toggles
  4. cf-ja4 / cf-ja4-signal etc. are automatically added to requests to the origin
[ Screenshot: Cloudflare dashboard > Rules > Managed Transforms > Add visitor location headers ]
Step 2: Convert cf-ja4 to X-Client-JA4 on the nginx side

Add this to the http {} scope:

# rename the cf-ja4 Cloudflare sends to the X-Client-JA4 that unmask reads.
# if cf-ja4 is empty, the fallback (direct origin access etc.) is an empty string โ†’ unmask
# falls back to the native module's self-computed $client_ja4.
map $http_cf_ja4 $http_x_client_ja4_remap {
    default $http_cf_ja4;
}

โ€ป unmask's standard map reads $http_x_client_ja4, so in some cases it is simpler to override it directly in the server scope with set $effective_ja4 $http_cf_ja4;.

Note: On Cloudflare's Free plan / Pro plan, cf-ja4 may not be emitted. A Business / Enterprise plan is required (as of 2025). Check Cloudflare's official docs for the latest plan requirements.
Spoofing prevention: Add a firewall (the origin server / ALB's security group / iptables) so nginx only accepts traffic from Cloudflare IPs (cloudflare.com/ips). Strip cf-ja4 / X-Client-JA4 from direct access that did not come via Cloudflare using if ($remote_addr ~ ...) on the nginx side.

AWS ALB / CloudFront

Unfortunately, as of 2025, neither AWS ALB nor CloudFront has a native JA4 extraction feature. There is also no mechanism for putting a template variable into a custom header (the equivalent of GCP's {client_ja4_fingerprint}). A workaround is needed.

Workaround A: Put Cloudflare in front

Place Cloudflare in front of the AWS ALB and let Cloudflare do the JA4 extraction. The simplest and most production-ready option. See the Cloudflare tab for details.

Workaround B: Compute it with Lambda@Edge

Bind a Lambda@Edge function to CloudFront to parse the TLS handshake at the viewer request stage โ†’ compute the JA4 โ†’ inject the header. Implement it per the ja4 spec. Parsing uses the aws-sdk family plus your own implementation.

// Lambda@Edge (Node.js) sketch โ€” viewer request event
exports.handler = async (event) => {
    const req = event.Records[0].cf.request;
    // CloudFront provides req.clientIp and some TLS info, but
    // the full ClientHello bytes do not arrive, so a complete JA4 implementation is hard.
    // Instead, a pragmatic implementation that builds a proxy
    // signature by combining a ja3 hash, clientTLSVersion, etc. is the realistic answer.
    req.headers['x-client-ja4'] = [{ key: 'X-Client-JA4', value: 'placeholder' }];
    return req;
};
Practical guidance: AWS load balancers do not expose the client JA4 today, so on AWS a JA4-less setup โ€” forward-auth mode with the UA filter, rate limit and honeypot โ€” is the realistic choice. unmask works without JA4; detection of stealth bots drops, but the other signals still catch most bots.
Workaround C: Bypass the ALB and terminate TLS directly on EC2

Bind an Elastic IP + ACM certificate directly to the EC2 instance and terminate TLS with nginx. nginx can then see the client TLS handshake directly via the unmask native module โ†’ it can obtain the complete JA4. However, you lose ALB features (sticky sessions, WAF integration).

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 the X-Client-JA4 header (overriding any client-sent value). Add the front layer's source CIDR under custom LB 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.

nginx + freenginx / FoxIO ja4 module

Switching to unmask's native mode is the easiest (an official module is provided). For install steps, see the native mode section of the install guide.

3. unmask-side setup

Once ยง2 has the front layer (LB / CDN) sending X-Client-JA4, the unmask side just decides which source to trust, in one place. The action is the same for native, forward-auth and Apache (admin โ†’ Settings โ†’ Network).

  1. Under “trusted LB / CDN”, check the LB / CDN you use (GCP HTTPS LB / Cloudflare / AWS CloudFront, โ€ฆ)
  2. For anything not listed (your own internal LB / ALB etc.), add its source CIDR + JA4 header under custom LB

On save, unmask renders a gate that adopts an X-Client-JA4 only when it comes from that source; a direct visitor's spoofed header is dropped. Then sudo nginx -s reload (or reload Apache); nothing is added to the server block / vhost 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); forward-auth (nginx) โ€” forward-auth-lbtrust.conf + the shipped server.inc; Apache (mod_lua) โ€” the connecting peer is handed to the daemon to decide. No separate toggle.

Notes: โ‘  if you forward-auth from a different host, add that nginx / Apache IP to “trusted LB / CDN” too. โ‘ก Apache adds one mod_rewrite line in the vhost to report the connecting peer (see the shipped apache-forward-auth.conf).

4. Verify it works (admin dashboard)

  1. After the nginx reload, access the target site from a normal browser
  2. The unmask admin stats tab โ†’ the "JA4 verdict distribution" card
  3. Verdicts are all (none) โ†’ the header is not arriving / it is being stripped by the trusted IP filter
  4. Bot names like chrome_fake_h1 appear in the verdicts โ†’ the JA4 is being obtained and classified โœ“
curl -sk https://your-domain/unmask/healthz -I 2>&1 | head
# check whether ja4=t13d... is recorded in the nginx access_log:
sudo tail -1 /var/log/nginx/access.log | grep ja4