TLS Fingerprinting: The Art of Identifying Bots by Their Handshake

Or: Why your User-Agent lies, but your TLS handshake doesn't

You know the drill: Your nginx log shows a request from “Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36”, a perfectly normal Chrome browser. Or is it? Spoiler: It’s a Python script trying to brute-force your /wp-login.php.

User-Agents are like business cards. Anyone can write whatever they want on them. But what if there was a way to identify clients by something they can’t easily fake?

Welcome to the world of TLS Fingerprinting.


What is TLS Fingerprinting?

“Show me your ClientHello, and I’ll tell you who you are”

When a client establishes an HTTPS connection, the first thing it sends is a TLS ClientHello. It’s basically saying “Hello, I’d like to communicate securely”, but with a ton of technical details:

Parameter What it reveals Example
TLS Version Supported protocol versions TLSv1.2, TLSv1.3
Cipher Suites Which encryption the client supports TLS_AES_128_GCM_SHA256
Extensions Additional features SNI, ALPN, supported_groups
Elliptic Curves Supported curves for ECDHE secp256r1, x25519
Signature Algorithms How certificates are verified RSA-PSS, ECDSA

This combination is like a digital fingerprint. Different clients (browsers, bots, scripts) have different implementations and therefore different fingerprints.

Pro tip: The fingerprint is generated before encryption. You can see it without decrypting the TLS session.


JA3: The De-Facto Standard

“Invented by Salesforce, used by everyone”

JA3 (named after its creators John Althouse, Jeff Atkinson, and Josh Atkins) is the most popular method for hashing TLS fingerprints:

JA3 = MD5(
    TLSVersion,
    Ciphers,
    Extensions,
    EllipticCurves,
    EllipticCurvePointFormats
)

The result is a 32-character MD5 hash like:

cd08e31494f9531f560d64c695473da9  →  Chrome 114 (Windows)
b4da7f95e46bf2cf5285fd609cf726e4  →  Googlebot
c199b43d41b470f8f68c5561f8f1ce3e  →  PetalBot
0149f47eabf9a20d0893e2a44e5a6323  →  AhrefsBot

Fun fact: There’s also JA3S, the counterpart for server responses. You can use it to identify malware C2 servers.


Why is This Useful?

“Your script can lie, your TLS library can’t”

Use Case 1: Bot Detection

Scenario User-Agent says JA3 says
Python requests “Chrome 120” cd09e31... (Python/urllib3)
curl “Mozilla/5.0” 473cd7c... (curl/OpenSSL)
Headless Chrome “Chrome 120” cd08e31... (real Chrome ✓)
Go http.Client “Chrome 120” ecdf4f4... (Go TLS Stack)

Use Case 2: Client Categorization

You can sort clients into categories:

map $http_ssl_ja3_hash $ja3_client_type {
    default                             "unknown";

    1. Browsers
    "cd08e31494f9531f560d64c695473da9"  "browser";   # Chrome Windows
    "68b3ecfaf0034bb9fcbecd518b5ab8d4"  "browser";   # Chrome Android
    "ecdf4f49dd59effc439639da29186671"  "browser";   # Safari iOS

    1. Known bots (allowed)
    "b4da7f95e46bf2cf5285fd609cf726e4"  "bot";       # Googlebot
    "c199b43d41b470f8f68c5561f8f1ce3e"  "bot";       # PetalBot
    "0149f47eabf9a20d0893e2a44e5a6323"  "bot";       # AhrefsBot

    1. Malicious
    "6734f37431670b3ab4292b8f60f29984"  "malicious"; # Metasploit
    "e7d705a3286e19ea42f587b344ee6865"  "malicious"; # Cobalt Strike
}

Use Case 3: Blocking Without Collateral Damage

1. Block bots on login pages
location = /wp-login.php {
    if ($ja3_client_type = "bot") {
        return 403;
    }
    1. ...
}

Implementation with nginx

“Because stock nginx can’t do this”

For JA3 support in nginx, you need a patched nginx build. The project phuslu/nginx-ssl-fingerprint does exactly that:

Dockerfile (simplified)

FROM ubuntu:22.04

1. Patch OpenSSL for JA3 extraction
RUN git clone https://github.com/nickvdp/openssl.git && \
    cd openssl && \
    ./config && make && make install

1. Build nginx with ssl_ja3 module
RUN git clone https://github.com/nickvdp/nginx.git && \
    cd nginx && \
    ./auto/configure \
        --with-http_ssl_module \
        --with-http_v2_module \
        --add-module=/path/to/nginx-ssl-fingerprint && \
    make && make install

nginx.conf

1. Log format with JA3
log_format ja3_json escape=json '{'
    '"time":"$time_iso8601",'
    '"remote_addr":"$remote_addr",'
    '"request":"$request",'
    '"status":$status,'
    '"ja3_hash":"$http_ssl_ja3_hash",'
    '"ja3_client_type":"$ja3_client_type",'
    '"http_user_agent":"$http_user_agent"'
'}';

1. Include JA3 map
map_hash_max_size 4096;
include /etc/nginx/ja3/ja3_fingerprints.map;

server {
    listen 443 ssl http2;

    1. Forward JA3 as header to backend
    proxy_set_header X-JA3-Hash $http_ssl_ja3_hash;
    proxy_set_header X-JA3-Client-Type $ja3_client_type;

    access_log /var/log/nginx/access.log ja3_json;
}

Limitations

“Nothing is perfect, not even fingerprints”

1. HTTP/3 (QUIC) = No JA3

This is the big catch. JA3 is based on the TLS handshake, but HTTP/3 uses QUIC, which has TLS 1.3 embedded. The handshake looks completely different.

1. This makes JA3 useless:
listen 443 quic;  # HTTP/3
listen 443 ssl;   # HTTP/2 + HTTP/1.1 (JA3 works here)
Protocol JA3 works?
HTTP/1.1 over TLS ✅ Yes
HTTP/2 over TLS ✅ Yes
HTTP/3 (QUIC) ❌ No

Pro tip: If you have HTTP/3 enabled, approximately 80 to 90 percent of your requests use QUIC and have no JA3 fingerprint.

2. Fingerprint Spoofing

Clever attackers can configure their TLS stack to look like Chrome. Tools like uTLS make this trivial:

// Go script with Chrome fingerprint
config := &utls.Config{
    ServerName: "example.com",
}
conn := utls.UClient(rawConn, config, utls.HelloChrome_Auto)

Therefore: JA3 is an additional factor, not a silver bullet. Combine it with:

  • IP reputation
  • Rate limiting
  • Behavioral analysis
  • Request pattern matching

3. Legitimate Clients with Same Fingerprint

The fingerprint 68b3ecfaf0034bb9fcbecd518b5ab8d4 is used by:

  • Real Chrome 116 Mobile users
  • Azure based vulnerability scanners

You can’t block this hash without hitting real users.


Quick Reference Card

┌─────────────────────────────────────────────────────────────┐
│                   TLS FINGERPRINTING                        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  JA3 = MD5(Version + Ciphers + Extensions + Curves)        │
│                                                             │
│  WORKS:         HTTP/1.1, HTTP/2 over TLS                  │
│  DOESN'T WORK:  HTTP/3 (QUIC)                              │
│                                                             │
│  USE CASES:                                                 │
│    ✓ Bot detection (Python, curl, Go)                      │
│    ✓ Crawler categorization (Google, Bing, SEO)            │
│    ✓ Malware detection (Metasploit, Cobalt Strike)         │
│    ✓ Additional auth factor                                │
│                                                             │
│  LIMITATIONS:                                               │
│    ✗ HTTP/3 = no fingerprints                              │
│    ✗ Fingerprint spoofing possible (uTLS)                  │
│    ✗ Collisions (same FP, different clients)               │
│                                                             │
│  PROJECTS:                                                  │
│    • github.com/phuslu/nginx-ssl-fingerprint                │
│    • github.com/salesforce/ja3                              │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Appendix: Known Fingerprints

JA3 Hash Client Type Threat
cd08e31494f9531f560d64c695473da9 Chrome 114 (Windows) Browser 🟢
68b3ecfaf0034bb9fcbecd518b5ab8d4 Chrome 116 (Android) Browser 🟢
ecdf4f49dd59effc439639da29186671 Safari iOS 18 Browser 🟢
b4da7f95e46bf2cf5285fd609cf726e4 Googlebot Crawler 🟢
a1180b5557791f9d36d36739d0d9b08a Bingbot Crawler 🟢
c199b43d41b470f8f68c5561f8f1ce3e PetalBot Crawler 🟡
0149f47eabf9a20d0893e2a44e5a6323 AhrefsBot Crawler 🟡
e4228d1fa6f13a432e4bc5ff54841b91 SemrushBot Crawler 🟡
473cd7cb9faa642487833865d516e578 curl/OpenSSL Tool 🟠
6734f37431670b3ab4292b8f60f29984 Metasploit Malicious 🔴
e7d705a3286e19ea42f587b344ee6865 Cobalt Strike Malicious 🔴
a0e9f5d64349fb13191bc781f81f42e1 Sliver C2 Malicious 🔴

Legend:

  • 🟢 Trusted: allow through
  • 🟡 Known: log, consider rate limiting
  • 🟠 Suspicious: monitor closely
  • 🔴 Malicious: block immediately

Conclusion

TLS Fingerprinting isn’t a replacement for other security measures, but it’s a powerful additional signal. It tells you which TLS library a client is really using, regardless of what the User-Agent claims.

Key Takeaways:

  1. JA3 is the standard for TLS fingerprinting
  2. Only works for HTTP/1.1 and HTTP/2 (not HTTP/3)
  3. Combine it with IP blocking and rate limiting
  4. Maintain a whitelist for legitimate crawlers (Googlebot, Bingbot, etc.)
  5. Expect fingerprint spoofing in targeted attacks

And remember: If someone configures their TLS stack to look like Chrome just to attack your /xmlrpc.php, that’s kind of a compliment to your security.


Stay fingerprinted, stay curious. 🔐


Further Reading:

Leave a Reply

Your email address will not be published. Required fields are marked *