Most bots don't bother forging a browser. They reach for a scripting library — a Go http.Client, Python requests, a headless automation stack — and every one of those speaks TLS a little differently than Chrome or Safari does. The order of cipher suites, which extensions the client advertises, the elliptic curves it supports: all of that is decided before a single HTTP byte crosses the wire, in the ClientHello. JA3 turns that ClientHello into a short, stable fingerprint. Ngris computes it at the TLS accept and lets you match on it from a traffic-policy rule — so a known-bad client can be turned away with a 403 without your origin ever seeing the request.
This is the bot-management rule. It's honest about what it is: a fingerprint filter, not a full anti-bot product. There's no CAPTCHA, no JavaScript challenge, no bot score. It does exactly two things — allow or deny by JA3 — and it defaults to observing, not blocking. Here's how it works and how you'd roll it out.
What JA3 actually is
JA3 (the technique originally published by Salesforce) fingerprints a TLS client from the values in its ClientHello. Ngris reads five fields, in this exact order, and joins them with commas:
# SSLVersion,Ciphers,Extensions,EllipticCurves,ECPointFormats
771,4865-4866,10-11-23,29-23,0
Every field is a list of decimal numbers joined with hyphens (an empty list renders as an empty string). SSLVersion is the ClientHello's legacy_version; Ciphers is the offered cipher-suite list; Extensions is the extension-type list; EllipticCurves comes from the supported_groups extension (type 0x000a); and ECPointFormats from the point-formats extension (type 0x000b). That canonical string is then hashed with MD5 and hex-encoded, giving you exactly 32 lowercase hex characters:
# md5 of the canonical string above — the fingerprint you match on
77c6f2f9c71516477eeb3971c06147ba
GREASE is stripped so the fingerprint stays stable
Modern browsers deliberately inject random GREASE values (RFC 8701) into their cipher, extension, and curve lists to keep the ecosystem tolerant of unknown values. If you fingerprinted those raw, the same Chrome build would produce a different JA3 on every connection. Ngris strips GREASE from the ciphers, extensions, and curves before hashing — GREASE values are the ones where both bytes are equal and the low nibble is 0xa (0x0a0a, 0x1a1a … 0xfafa) — so a given browser build produces a stable fingerprint you can safely put on a list. (EC point formats are not GREASE-filtered, because GREASE doesn't appear there.) In the example above, the raw ClientHello also carried GREASE ciphers and a GREASE extension; both were dropped before the string was built.
The parse is peek-only. Ngris reads the buffered ClientHello without consuming it, so JA3 computation composes cleanly with the SNI lookup that runs on the same connection — one peek, both answers.
Computed at the handshake, gated so non-users pay nothing
The fingerprint is computed once per connection, on the TLS accept path, from the same ClientHello already peeked for SNI. It's constant for the life of the connection and carried through to the HTTP handlers.
Critically, that work is gated. A single atomic.Bool is read on the hot accept path and is only ever set true when at least one enabled bot-management rule exists somewhere across your loaded traffic policies. If nobody in the fleet is using the feature, JA3 is never computed and connections are byte-identical to before — endpoints that don't opt in pay nothing. The flag flips on a policy reload, not per request.
One nuance worth stating plainly: the fingerprint is captured at the handshake, but the match and the 403 happen in the on_http_request phase, where the rule runs. This isn't a handshake-level rejection like mutual TLS — a denied bot gets a plain 403 Forbidden, and the request simply never reaches your origin. The bot-management rule type is pinned to on_http_request; the save API rejects it in any other phase.
The rule: two modes, a denylist, and an allowlist
Bot management is one of Ngris's traffic-policy rule types (gated per plan by the traffic_rule:bot-management entitlement). Its config is small:
# BotManagementConfig — start here, in detect mode
{
"mode": "detect",
"ja3_denylist": [],
"ja3_allowlist": [],
"block_missing": false
}
The knobs:
mode—"block"denies a matched request with HTTP 403; anything else (including empty) is treated as"detect", which only logs. Detect is the default, so enabling the rule on a live endpoint can't break traffic on day one.ja3_denylist— JA3 hashes to block. This is your known-bad list.ja3_allowlist— when non-empty, any request whose JA3 is not on the list is blocked. This is the "allow known clients only" posture. An empty allowlist means no restriction — it does not block everything.block_missing— block when no JA3 could be computed (a non-TLS client, or a ClientHello that failed to parse). Off by default: a missing fingerprint is not proof of a bot.message— overrides the block reason shown to the client. Defaults to"Request blocked by bot management".
The matching order is deterministic: parse the config, then check the missing-fingerprint case (governed by block_missing), then the denylist, then the allowlist, otherwise allow. The fingerprints you paste into a list are whitespace-trimmed and compared case-insensitively, so a stray space or upper-case hash won't silently miss.
The rollout: run detect, read the log, then block
Because JA3 is GREASE-normalized and stable, the intended workflow is to let the rule watch first and build your list from what it actually sees. Turn the rule on in detect mode and every matched connection is logged with its exact fingerprint:
# detect-mode log line (quoted ja3, ready to copy)
bot-management: DETECT endpoint=42 ip=203.0.113.7 ja3="e7d705a3286e19ea42f587b344ee6865" reason=ja3 in denylist (not blocked)
Watch which fingerprints hammer your login route or scrape your catalog, copy the ones you're confident about into ja3_denylist, and flip mode to block:
# now enforcing — matched clients get a 403
{
"mode": "block",
"ja3_denylist": ["e7d705a3286e19ea42f587b344ee6865"],
"message": "Request blocked by bot management"
}
In block mode the log flips to bot-management: BLOCK endpoint=… ip=… ja3=… reason=… and the request is denied. This "detect first, then block" default is the whole point: you never enforce against a fingerprint you haven't seen in your own logs. For a high-value internal API where you know exactly which clients should ever connect, invert it — put your legitimate fingerprints in ja3_allowlist and everything else is turned away.
conn.ja3 works in any rule's condition, not just this one
The same fingerprint is exposed to Ngris's CEL policy language as conn.ja3 — a string, or "" when it couldn't be computed. That means you can guard any traffic-policy rule on JA3, not only the bot-management rule. A few things that buys you:
# only rate-limit one specific fingerprint (guard on a rate-limit rule)
conn.ja3 == "e7d705a3286e19ea42f587b344ee6865"
# serve a custom 403 body to a scraper only under /pricing
req.path.startsWith("/pricing") && conn.ja3 == "e7d705a3286e19ea42f587b344ee6865"
# tighten a rule only for clients that couldn't be fingerprinted
conn.ja3 == ""
Because every rule short-circuits on the first non-continue action in priority order, you can layer these: put a narrow conn.ja3-guarded redirect or custom-response ahead of a broad allow, and the specific fingerprint gets special handling while everything else falls through untouched.
Where JA3 pairs well: the WAF
Bot management is a coarse who-are-you filter. Ngris's built-in WAF (a Coraza/ModSecurity-compatible engine) is the what-are-they-sending filter — SQLi, XSS, path traversal, Log4Shell, and a bad-scanner User-Agent list. The two compose naturally, and they share the same safe-by-default philosophy: the WAF also ships in detect mode (log-only) so you can watch before you flip it to block. A common shape is a JA3 denylist for the clients you never want, plus the WAF watching request content — both observing first, both enforced once you trust the signal.
Honest limits
This feature is deliberately narrow, and it's worth being upfront about the edges:
- JA3 only — no JA4. The shipped fingerprint is classic JA3. There is no JA4 variant in the code, despite the temptation to imply one.
- TLS over TCP only — no QUIC / HTTP/3. The fingerprint comes from the TLS ClientHello on the TCP accept path. There's no QUIC ClientHello fingerprinting.
- No challenge, no CAPTCHA, no bot score. The only outcomes are allow or a 403. There's no interactive challenge, no JavaScript interstitial, and no per-JA3 rate limiting baked into this rule (use a CEL guard on a
rate-limitrule if you want that). - Fingerprints are spoofable. Tooling that mimics a real browser's TLS stack (utls-style libraries do exactly this) will share a browser's JA3. JA3 raises the cost of blending in; it doesn't make it impossible. Treat it as one signal, not the whole defense.
- It's a filter, not an analytics store. The fingerprint is logged and carried in request context (and CEL); this rule doesn't persist JA3 to a database or emit it as its own metric.
Used for what it's good at — cheaply turning away obvious scripted clients, or locking a sensitive endpoint to a short list of known fingerprints — JA3 bot management earns its place. Turn it on in detect, read your own logs for a few days, and promote the fingerprints you're sure about to block.
Questions about rolling out bot management on your endpoints? Reach us at hi@ngris.com.