Nginx built with JA4(+) fingerprinting — module, OpenSSL/nginx patches, and an access-log format that records fingerprints on every request. Optionally pair with Suricata 8 for wire/L4 JA4 in eve.json.
| Path | Purpose |
|---|---|
ja4-nginx-module/ |
JA4 nginx module source + OpenSSL/nginx patches |
configs/nginx-ja4-access.conf |
Access-log config using $http_ssl_ja4* variables |
configs/suricata-ja4.yaml.snippet |
Suricata 8 JA4 / eve-log / af-packet snippets |
configs/check-suricata-ja4.sh |
Verify JA4 (+ SNI) in /var/log/suricata/eve.json |
scripts/install-system-nginx.sh |
Optional: install JA4 build as system /usr/sbin/nginx |
scripts/ja4-dashboard.py |
Terminal dashboard: nginx + Suricata JA4 overview |
README.md |
Build & run guide (nginx 1.28.0 + OpenSSL 3.0.13) |
Local
nginx/andja4-openssl/build trees are not committed (too large). Download and patch them using the steps below.
| Layer | Where | Log |
|---|---|---|
| L5 (app) | This nginx module | /var/log/nginx/access.log → ja4=, ja4h=, … |
| L4 (wire) | Suricata on the NIC | /var/log/suricata/eve.json → tls.ja4, tls.sni |
They are independent. Nginx sees HTTP(S) at the process; Suricata sees packets on an interface (e.g. enp0s3). Traffic to 127.0.0.1 will show up in nginx logs but not in Suricata if Suricata is not capturing lo.
Custom log_format ja4_extended writes fingerprints alongside the usual request fields:
log_format ja4_extended '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'ja4="$http_ssl_ja4" '
'ja4_string="$http_ssl_ja4_string" '
'ja4one="$http_ssl_ja4one" '
'ja4s="$http_ssl_ja4s" '
'ja4s_string="$http_ssl_ja4s_string" '
'ja4h="$http_ssl_ja4h" '
'ja4h_string="$http_ssl_ja4h_string" '
'ja4t="$http_ssl_ja4t" '
'ja4t_string="$http_ssl_ja4t_string" '
'ja4ts="$http_ssl_ja4ts" '
'ja4ts_string="$http_ssl_ja4ts_string" '
'ja4l="$http_ssl_ja4l" '
'ja4x="$https_ssl_ja4x" '
'ja4x_string="$https_ssl_ja4x_string" '
'ja4_l4="$http_x_ja4_l4"';
access_log /var/log/nginx/access.log ja4_extended;Full example: [configs/nginx-ja4-access.conf](configs/nginx-ja4-access.conf).
| Variable | Fingerprint |
|---|---|
$http_ssl_ja4 / _string |
Client TLS (JA4) |
$http_ssl_ja4s / _string |
Server TLS (JA4S) |
$http_ssl_ja4h / _string |
HTTP (JA4H) |
$http_ssl_ja4t / _string |
TCP (JA4T) |
$http_ssl_ja4ts / _string |
TCP server (JA4TS) |
$http_ssl_ja4l |
Latency (JA4L) |
$https_ssl_ja4x / _string |
Certificate (JA4X) |
$http_x_ja4_l4 |
Optional L4 JA4 from Suricata (header) |
TLS fields need HTTPS. ja4h works on plain HTTP.
mkdir ja4-nginx-build && cd ja4-nginx-buildwget http://nginx.org/download/nginx-1.28.0.tar.gz
tar -xzf nginx-1.28.0.tar.gz
mv nginx-1.28.0 nginxwget https://github.com/openssl/openssl/releases/download/openssl-3.0.13/openssl-3.0.13.tar.gz
tar -xzf openssl-3.0.13.tar.gz
mv openssl-3.0.13 ja4-openssl# Clone this repo, then:
ln -s /path/to/JA4nginx/ja4-nginx-module .
# or: cp -a /path/to/JA4nginx/ja4-nginx-module .
cd nginx./configure \
--with-debug \
--with-compat \
--add-module=../ja4-nginx-module/src \
--with-http_ssl_module \
--with-openssl=$(pwd)/../ja4-openssl \
--prefix=$(pwd)/nginx_localcd ../ja4-openssl
./Configure linux-x86_64 --prefix=$(pwd)/.openssl --openssldir=$(pwd)/.openssl
patch -p1 < ../ja4-nginx-module/patches/openssl.patch
make -j$(nproc)
sudo make install_sw LIBDIR=libcd ../nginx
patch -p1 < ../ja4-nginx-module/patches/nginx.patch
make -j$(nproc)
sudo make install
sudo chown "$(whoami)" ./nginx_localcp /path/to/JA4nginx/configs/nginx-ja4-access.conf ./nginx_local/conf/nginx.conf
# Enable the HTTPS server block + certs for TLS fingerprints./nginx_local/sbin/nginx
tail -f /var/log/nginx/access.logDestructive: replaces /usr/sbin/nginx and adds a systemd drop-in. Backs up first.
Ensure nginx_local/conf/nginx.conf has:
pid /run/nginx.pid;(required for systemdType=forking)access_log/error_logpaths nginx can write (e.g./var/log/nginx/)- HTTPS
listen 443 ssl+cert.pem/cert.keyif you want TLS JA4
Then (from the build tree, or pass the prefix explicitly):
# After build: prefix is ./nginx/nginx_local (or wherever you installed)
sudo /path/to/JA4nginx/scripts/install-system-nginx.sh /path/to/nginx/nginx_local
# Verify
nginx -V 2>&1 | head -3
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1/
curl -skS -o /dev/null -w '%{http_code}\n' https://127.0.0.1/
sudo tail -2 /var/log/nginx/access.logInstall without starting: sudo SKIP_START=1 .../install-system-nginx.sh /path/to/nginx_local.
Restore notes are printed by the script (backup under /etc/nginx/backup_*).
Live terminal view of nginx access-log fingerprints and Suricata IDS TLS JA4 from eve.json (top counts, recent events, rare JA4s, IPs with multiple JA4s).
# Needs read access to both logs (usually root)
sudo python3 /path/to/JA4nginx/scripts/ja4-dashboard.py
# One-shot (scripting / pipes)
sudo python3 /path/to/JA4nginx/scripts/ja4-dashboard.py --once --plain
# Options
sudo python3 /path/to/JA4nginx/scripts/ja4-dashboard.py \
--nginx-log /var/log/nginx/access.log \
--eve /var/log/suricata/eve.json \
--interval 2 \
--tail 5000 \
--top 10 \
--recent 8- Press
qthen Enter (or Ctrl-C) to quit live mode. - Optional prettier UI if
[rich](https://github.com/Textualize/rich)is installed (pip install rich); otherwise plain text. - Same-host curls may appear in nginx but not Suricata (
enp0s3only) — hit the LAN IP from another machine for L4 rows.
Illustrative live view (SNIs anonymized). Nginx rows are local test clients; Suricata rows are outbound TLS on the NIC — often different flows until a remote client hits this host.
╭──────────────────────────────── status ────────────────────────────────╮
│ JA4 dashboard 2026-07-24 18:20:46 UTC │
│ nginx OK /var/log/nginx/access.log │
│ eve OK /var/log/suricata/eve.json │
│ events nginx=6 suricata_tls+ja4=86 L5 nginx + L4 Suricata IDS │
╰────────────────────────────────────────────────────────────────────────╯
Nginx top ja4
┏━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ count ┃ ja4 ┃
┡━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 3 │ t13i3011h2_1d37bd780c83_b26ce05bbdd6 │
└────────┴──────────────────────────────────────┘
Nginx top ja4h
┏━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ count ┃ ja4h ┃
┡━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 6 │ ge11nn030000_b51846f30ce9_e3b0c44298fc_e3b0c44298fc │
└────────┴─────────────────────────────────────────────────────┘
Nginx recent
┏━━━━━━━━━━━━━━━━━┳━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ src ┃ st ┃ req ┃ ja4 ┃ ja4h ┃
┡━━━━━━━━━━━━━━━━━╇━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 127.0.0.1 │ 200 │ GET / HTTP/1.1 │ - │ ge11nn030000_b51846f30ce9_e3b... │
│ 127.0.0.1 │ 200 │ GET / HTTP/1.1 │ t13i3011h2_1d37bd780c83_b26ce05bbdd6 │ ge11nn030000_b51846f30ce9_e3b... │
│ 127.0.0.1 │ 200 │ GET / HTTP/1.1 │ - │ ge11nn030000_b51846f30ce9_e3b... │
│ 127.0.0.1 │ 200 │ GET / HTTP/1.1 │ t13i3011h2_1d37bd780c83_b26ce05bbdd6 │ ge11nn030000_b51846f30ce9_e3b... │
│ 192.168.110.33 │ 200 │ GET / HTTP/1.1 │ - │ ge11nn030000_b51846f30ce9_e3b... │
│ 192.168.110.33 │ 200 │ GET / HTTP/1.1 │ t13i3011h2_1d37bd780c83_b26ce05bbdd6 │ ge11nn030000_b51846f30ce9_e3b... │
└─────────────────┴─────┴────────────────────────┴──────────────────────────────────────┴──────────────────────────────────┘
Suricata top ja4 + sni
┏━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ count ┃ ja4 ┃ sni ┃
┡━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 33 │ t13d1011h1_61a7ad8aa9b6_d4929ced8316 │ api.example.com │
│ 31 │ t13d521200_b262b3658495_66863fb0a24c │ api.example.com │
│ 22 │ t13d5212h2_b262b3658495_8e6e362c5eac │ cdn.example.com │
└────────┴──────────────────────────────────────────┴──────────────────────────────────┘
Suricata recent tls
┏━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ time ┃ src ┃ dst ┃ ja4 ┃ sni ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 2026-07-24T18:16:56.5... │ 192.168.110.33 │ 54.156.149.72:443 │ t13d521200_b262b3658495_66863fb... │ api.example.com │
│ 2026-07-24T18:19:10.9... │ 192.168.110.33 │ 52.6.16.241:443 │ t13d521200_b262b3658495_66863fb... │ api.example.com │
│ 2026-07-24T18:19:12.3... │ 192.168.110.33 │ 52.6.16.241:443 │ t13d521200_b262b3658495_66863fb... │ api.example.com │
│ 2026-07-24T18:19:14.7... │ 192.168.110.33 │ 54.80.240.195:443 │ t13d521200_b262b3658495_66863fb... │ api.example.com │
│ 2026-07-24T18:19:15.7... │ 192.168.110.33 │ 3.228.152.244:443 │ t13d521200_b262b3658495_66863fb... │ api.example.com │
│ 2026-07-24T18:19:17.0... │ 192.168.110.33 │ 34.226.129.221:443 │ t13d521200_b262b3658495_66863fb... │ api.example.com │
│ 2026-07-24T18:20:05.7... │ 192.168.110.33 │ 34.199.61.39:443 │ t13d5212h2_b262b3658495_8e6e362... │ cdn.example.com │
│ 2026-07-24T18:20:34.0... │ 192.168.110.33 │ 54.235.102.83:443 │ t13d1011h1_61a7ad8aa9b6_d4929ce... │ api.example.com │
└──────────────────────────┴────────────────┴──────────────────────┴────────────────────────────────────┴──────────────────────────┘
Triage: rare ja4 (count=1)
┏━━━━━━━━━━━━┳━━━━━━━━┓
┃ source ┃ ja4 ┃
┡━━━━━━━━━━━━╇━━━━━━━━┩
│ - │ (none) │
└────────────┴────────┘
Triage: IPs with multiple ja4
┏━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━┓
┃ source ┃ ip ┃ n ┃
┡━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━┩
│ suricata │ 192.168.110.33 │ 3 │
└────────────┴────────────────────┴──────┘
q + Enter quit | Ctrl-C quit
Requires pip install rich (otherwise the dashboard falls back to plain text).
One-shot Suricata-only check remains: [configs/check-suricata-ja4.sh](configs/check-suricata-ja4.sh).
Suricata 8.x in IDS mode (passive AF_PACKET — not inline IPS) with HAVE_JA4 writes client JA4 into TLS eve events. Snippets: [configs/suricata-ja4.yaml.snippet](configs/suricata-ja4.yaml.snippet).
sudo apt update
sudo apt install -y suricata # 8.0+ recommended
suricata --build-info | grep -i JA4 # expect: JA4 support: yes- Generate fingerprints under
app-layer.protocols.tls:
app-layer:
protocols:
tls:
enabled: yes
ja3-fingerprints: yes
ja4-fingerprints: yes- Log them under
outputs→eve-log→types:
- eve-log:
enabled: yes
filetype: regular
filename: eve.json
types:
- tls:
extended: yes # includes ja4 + sni (among other fields)- Capture NIC under
af-packet(replace with your interface):
af-packet:
- interface: enp0s3
- interface: default- Restart:
sudo systemctl restart suricata
sudo systemctl status suricatasudo bash /path/to/JA4nginx/configs/check-suricata-ja4.sh
# or:
sudo grep -o '"ja4":"[^"]*"' /var/log/suricata/eve.json | sort | uniq -c | sort -rn | headJA4 + SNI counts:
sudo jq -r 'select(.event_type=="tls") | "\(.tls.ja4 // "-")\t\(.tls.sni // "-")"' /var/log/suricata/eve.json \
| sort | uniq -c | sort -rn | headIf logs show failed to create socket directory /var/run/suricata/: Permission denied, Suricata still captures but suricatasc / clean stops break. Fix:
sudo mkdir -p /etc/systemd/system/suricata.service.d
sudo tee /etc/systemd/system/suricata.service.d/runtime.conf >/dev/null <<'EOF'
[Service]
RuntimeDirectory=suricata
RuntimeDirectoryMode=0755
EOF
sudo systemctl daemon-reload
sudo systemctl restart suricataThe nginx log_format includes ja4_l4="$http_x_ja4_l4". That only fills if something (e.g. a local forwarder) sets request header X-JA4-L4 from Suricata. Out of the box, nginx L5 and Suricata L4 stay in separate logs.
ja4-nginx-module/patches/nginx.patch— ClientHello early callback / extension capture for JA4ja4-nginx-module/patches/openssl.patch— OpenSSL helpers needed by the module
Based on FoxIO-LLC/ja4-nginx-module (FoxIO License 1.1).
nginx and OpenSSL remain under their respective licenses.
Private — all rights reserved for this repo’s config and docs.
Module/patches are subject to FoxIO License 1.1.