DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

Building a TLS Fingerprinting Tool with Go (JA3/JA4)

Every TLS client leaves a fingerprint. The combination of cipher suites, extensions, and elliptic curves advertised in the ClientHello message is unique enough to distinguish a Python requests call from a curl command from a Cobalt Strike beacon — even when the payload is fully encrypted. JA3 and its successor JA4 turn that fingerprint into a reproducible hash you can act on, and Go is a natural fit for building tools around it.

What TLS Fingerprinting Is and Why It Matters

When a TLS handshake begins, the client sends a ClientHello message that lists:

  • TLS version
  • Supported cipher suites (in order)
  • Supported extensions (in order)
  • Supported elliptic curve groups
  • Elliptic curve point formats

This data is always unencrypted and is essentially fixed for a given client library and version. A production bot scraping your API will produce the same ClientHello on every request, regardless of destination. JA3 concatenates specific fields from that message and MD5-hashes them into a 32-character string. JA4 improves on this by sorting fields before hashing, making fingerprints stable across minor client differences and human-readable at a glance.

On the defensive side, a WAF or reverse proxy that fingerprints incoming connections can flag scrapers mimicking a browser, catch C2 framework check-ins before they reach your application, or identify hosts still running outdated TLS stacks. This is a cheap, passive signal — no payload decryption, no latency overhead. If you want a broader look at layered TLS hardening, our free security hardening checklists cover certificate policies, cipher suite selection, and HSTS alongside fingerprinting.

The JA3 Hash Formula

JA3 concatenates five fields with a predictable separator scheme:

TLSVersion,Ciphers,Extensions,EllipticCurves,EllipticCurvePointFormats
Enter fullscreen mode Exit fullscreen mode

Each list is dash-separated. GREASE values (0x0a0a, 0x1a1a, ... per RFC 8701) are stripped from all lists before hashing. The result is the MD5 of that string.

A concrete raw string looks like:

771,4866-4867-4865-49196-49200,0-23-65281-10-11-35-16-5-13,29-23-24,0
Enter fullscreen mode Exit fullscreen mode

MD5 of that gives 9b0a1e74c72f9ab20e6379dffde7a5de

JA4 changes the format to a structured prefix plus two truncated SHA-256 hashes:

t13d1516h2_8daaf6152771_b1ff8ab2d16f
Enter fullscreen mode Exit fullscreen mode

The prefix t13d1516h2 encodes: TLS (t), version 1.3 (13), domain (d), 15 cipher suites, 16 extensions, HTTP/2 ALPN (h2). You can read the client type without decoding anything.

Parsing a ClientHello in Go

The standard library does not expose raw TLS handshake bytes, so you parse them yourself — either from gopacket captures or from a buffered listener (shown in the next section). Here is the core hash logic as a standalone package:

package fingerprint

import (
    "crypto/md5"
    "encoding/hex"
    "fmt"
    "strings"
)

// greaseValues lists GREASE constants (RFC 8701) that must be filtered before hashing.
var greaseValues = map[uint16]bool{
    0x0a0a: true, 0x1a1a: true, 0x2a2a: true, 0x3a3a: true,
    0x4a4a: true, 0x5a5a: true, 0x6a6a: true, 0x7a7a: true,
    0x8a8a: true, 0x9a9a: true, 0xaaaa: true, 0xbaba: true,
    0xcaca: true, 0xdada: true, 0xeaea: true, 0xfafa: true,
}

// ClientHello holds the fields relevant to TLS fingerprinting.
type ClientHello struct {
    Version         uint16
    CipherSuites    []uint16
    Extensions      []uint16
    SupportedGroups []uint16
    PointFormats    []uint8
    SNI             string
    ALPNProtocols   []string
}

// JA3String builds the raw concatenated string before hashing.
func JA3String(h *ClientHello) string {
    return fmt.Sprintf("%d,%s,%s,%s,%s",
        h.Version,
        joinU16(filterGREASE(h.CipherSuites), "-"),
        joinU16(filterGREASE(h.Extensions), "-"),
        joinU16(filterGREASE(h.SupportedGroups), "-"),
        joinU8(h.PointFormats, "-"),
    )
}

// JA3Hash returns the MD5 fingerprint of a ClientHello.
func JA3Hash(h *ClientHello) string {
    sum := md5.Sum([]byte(JA3String(h)))
    return hex.EncodeToString(sum[:])
}

func filterGREASE(in []uint16) []uint16 {
    out := make([]uint16, 0, len(in))
    for _, v := range in {
        if !greaseValues[v] {
            out = append(out, v)
        }
    }
    return out
}

func joinU16(s []uint16, sep string) string {
    parts := make([]string, len(s))
    for i, v := range s {
        parts[i] = fmt.Sprintf("%d", v)
    }
    return strings.Join(parts, sep)
}

func joinU8(s []uint8, sep string) string {
    parts := make([]string, len(s))
    for i, v := range s {
        parts[i] = fmt.Sprintf("%d", v)
    }
    return strings.Join(parts, sep)
}
Enter fullscreen mode Exit fullscreen mode

This is pure and stateless — give it a ClientHello, get a deterministic hash. Testing it is straightforward: capture a few handshakes with Wireshark, extract the ClientHello fields manually, and compare against the salesforce/ja3 reference dataset.

Building a Fingerprinting Listener

The listener approach fingerprints every connection at the TCP level before TLS negotiates. The trick is to wrap net.Conn so the bytes we peek at are replayed to crypto/tls unchanged:

package main

import (
    "crypto/tls"
    "log"
    "net"
    "net/http"

    "fingerprint"
)

// peekConn replays buffered bytes so the TLS stack sees the complete handshake.
type peekConn struct {
    net.Conn
    buf []byte
}

func (c *peekConn) Read(b []byte) (int, error) {
    if len(c.buf) > 0 {
        n := copy(b, c.buf)
        c.buf = c.buf[n:]
        return n, nil
    }
    return c.Conn.Read(b)
}

type fpListener struct{ inner net.Listener }

func (l *fpListener) Accept() (net.Conn, error) {
    conn, err := l.inner.Accept()
    if err != nil {
        return nil, err
    }
    raw := make([]byte, 1024)
    n, err := conn.Read(raw)
    if err != nil {
        conn.Close()
        return nil, err
    }
    raw = raw[:n]
    if hello, err := fingerprint.ParseClientHello(raw); err == nil {
        log.Printf("addr=%s ja3=%s sni=%q",
            conn.RemoteAddr(), fingerprint.JA3Hash(hello), hello.SNI)
    }
    return &peekConn{Conn: conn, buf: raw}, nil
}

func (l *fpListener) Close() error   { return l.inner.Close() }
func (l *fpListener) Addr() net.Addr { return l.inner.Addr() }

func main() {
    cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
    if err != nil {
        log.Fatal(err)
    }
    raw, err := net.Listen("tcp", ":8443")
    if err != nil {
        log.Fatal(err)
    }
    tl := tls.NewListener(&fpListener{raw}, &tls.Config{
        Certificates: []tls.Certificate{cert},
    })
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
        w.Write([]byte("ok"))
    })
    log.Println("listening on :8443")
    log.Fatal(http.Serve(tl, mux))
}
Enter fullscreen mode Exit fullscreen mode

ParseClientHello (not shown here) walks the raw TLS record bytes to populate the ClientHello struct. The byte layout is well-documented: 5-byte record header, 4-byte handshake header, then the ClientHello body. The Go standard library crypto/tls source is the cleanest reference for the exact offsets.

Every accepted connection now emits a structured log line with the remote address, JA3 hash, and SNI before any HTTP layer sees it. Pipe that into your log aggregator and you have a passive fingerprinting layer with near-zero overhead.

JA4: What Changed and Why

JA4 was proposed by FoxIO in 2023 to address JA3's main weaknesses.

Sorted before hashing. JA3 hashes fields in the order the client sends them. Two clients advertising identical capabilities in a different extension order get different JA3 hashes. JA4 sorts cipher suites and extensions before hashing, so the fingerprint reflects what the client supports rather than the arbitrary order of its implementation.

Human-readable prefix. The prefix t13d1516h2 encodes TLS version, SNI presence, cipher count, extension count, and ALPN — all readable without decoding the hash itself. A security analyst can parse a JA4 fingerprint in a log at a glance.

Truncated SHA-256. Two 12-character truncated SHA-256 hashes replace a single MD5. The two-part structure separates cipher fingerprint from extension fingerprint, which is useful when matching partial client profiles across different TLS versions.

The Go implementation follows the same parsing path as JA3. The difference is in the hash step: sort the filtered cipher and extension lists before feeding them to SHA-256, then take the first 12 hex characters of each. The driftnet/ja4 package is a clean reference implementation if you want to skip the wire format details.

The Takeaway

JA3 and JA4 give you a passive, encryption-transparent signal on every TLS connection. The ClientHello is always in the clear, and computing its hash adds microseconds of overhead per connection. In Go, the implementation maps directly onto the language's net abstractions: a custom listener buffers the handshake bytes, a parser extracts the fields, and a deterministic hash function does the rest.

Concrete uses that deliver immediate value:

  • Log JA3 hashes alongside request IDs in your reverse proxy — instant timeline correlation during security incidents
  • Build a fingerprint baseline from 48 hours of legitimate traffic and alert on deviations
  • Cross-reference hashes against threat intel lists (Emerging Threats publishes JA3 indicators)
  • Extend to JA4S (the server-side equivalent) to audit what TLS stack your servers expose to clients

The fingerprint alone is not a firewall rule. A determined attacker can spoof ClientHello fields in seconds. But commodity tooling — scrapers, scanners, most malware — does not bother to randomize its TLS profile. As one layer in a defense stack, fingerprinting catches a meaningful share of noise before it reaches your application logic.


I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.

Top comments (0)