DEV Community

Cover image for Zero-Dependency HTTP Fingerprinting Library in Go: Bot Detection with JA3
Maksat Ramazan
Maksat Ramazan

Posted on

Zero-Dependency HTTP Fingerprinting Library in Go: Bot Detection with JA3

Last month I was building an API for a fintech project. We had rate limiting in place (using kazrl, actually), but bots kept slipping through. They'd rotate IPs, spoof User-Agents, and our simple checks were useless.

I needed something smarter. Something that could identify clients by how they make requests, not just what they send.

That's when I went down the rabbit hole of HTTP fingerprinting. And after reading papers on JA3, TLS fingerprinting, and header analysis — I realized there was no simple Go library that did all of this without pulling in half the internet as dependencies.

So I built reqdna — a zero-dependency HTTP fingerprinting library for Go 1.26+.


What is HTTP Fingerprinting?

Every HTTP client leaves a unique "fingerprint" based on:

  • TLS handshake — cipher suites, extensions, curves offered
  • Header order — browsers send headers in specific sequences
  • Header presence — real browsers send Accept-Language, bots often don't
  • User-Agent patterns — obvious, but still useful in combination

The key insight: even if a bot spoofs the User-Agent, it can't easily fake the TLS handshake. That's where JA3 comes in.


JA3: The TLS Fingerprint

JA3 is a method developed by Salesforce to fingerprint TLS clients. It extracts data from the ClientHello message:

Format: SSLVersion,Ciphers,Extensions,EllipticCurves,ECPointFormats
Example: 771,4865-4866-4867-49196,0-23-65281-10-11,29-23-24,0
Hash: MD5  ada70206e40642a3e4461f35503241d5
Enter fullscreen mode Exit fullscreen mode

Every browser version has a unique JA3 hash. Chrome, Firefox, curl, Python requests — all different. And here's the kicker: it's extremely hard to spoof.

To fake a JA3 fingerprint, you'd need to modify the TLS library itself. Most bots don't bother.

Client JA3 Hash
Chrome b32309a26951912be7dba376398abc3b
Firefox 839bbe3ed07fed922ded5aaf714d6842
curl 456523fc94726331a4d5a2e1d40b2cd7
Python requests 3b5074b1b5d032e5620f69f9f700ff0e

Enter reqdna

Here's the simplest usage:

package main

import (
    "fmt"
    "net/http"
    "github.com/aqylsoft/reqdna"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fp := reqdna.FromRequest(r)

        fmt.Println("Fingerprint:", fp.Hash[:16])
        fmt.Println("Bot Score:", fp.BotScore)
        fmt.Println("Is Bot:", fp.IsBot())
        fmt.Println("Device:", fp.Device.Type, fp.Device.Browser)
    })

    http.ListenAndServe(":8080", nil)
}
Enter fullscreen mode Exit fullscreen mode

That's it. 10 lines and you have:

  • Stable fingerprint hash
  • Bot probability score (0.0 - 1.0)
  • Device/browser detection
  • IP analysis (hashed for GDPR compliance)

The Fingerprint Structure

type Fingerprint struct {
    Hash        string      // Stable SHA256 fingerprint
    IP          IPInfo      // IP metadata (hashed by default)
    TLS         TLSInfo     // TLS fingerprint with JA3
    Headers     HeaderInfo  // Header analysis
    Device      DeviceInfo  // OS, browser, device type
    BotScore    float64     // 0.0 - 1.0 probability
    RequestedAt time.Time
}
Enter fullscreen mode Exit fullscreen mode

The BotScore is calculated from multiple signals:

  • Missing standard headers (User-Agent, Accept, Accept-Language)
  • Low header count (bots send minimal headers)
  • Known bot patterns in User-Agent
  • Header entropy (too uniform = suspicious)
  • Outdated TLS versions
  • Device type detection

Full JA3 Fingerprinting

Here's where it gets interesting. Go's crypto/tls package exposes ClientHelloInfo through the GetConfigForClient callback. This contains everything we need for JA3:

  • SupportedVersions
  • CipherSuites
  • Extensions (added in Go 1.21!)
  • SupportedCurves
  • SupportedPoints

No external dependencies. No C bindings. Pure Go stdlib.

Here's how to enable full JA3:

package main

import (
    "crypto/tls"
    "fmt"
    "net/http"
    "github.com/aqylsoft/reqdna"
)

func main() {
    // 1. Create store to capture ClientHello
    store := reqdna.NewClientHelloStore()

    // 2. Wrap your TLS config
    tlsConfig := reqdna.WrapTLSConfig(&tls.Config{
        MinVersion: tls.VersionTLS12,
    }, store)

    // 3. Use JA3-aware middleware
    mux := http.NewServeMux()
    mux.HandleFunc("/", handler)

    wrapped := reqdna.MiddlewareWithJA3(mux, store)

    // 4. Start HTTPS server
    server := &http.Server{
        Addr:      ":443",
        Handler:   wrapped,
        TLSConfig: tlsConfig,
    }
    server.ListenAndServeTLS("cert.pem", "key.pem")
}

func handler(w http.ResponseWriter, r *http.Request) {
    fp, _ := reqdna.Get(r.Context())

    fmt.Fprintf(w, "Your fingerprint: %s\n", fp.Hash[:16])
    fmt.Fprintf(w, "Bot score: %.2f\n", fp.BotScore)

    if fp.TLS.JA3 != nil {
        fmt.Fprintf(w, "JA3 Hash: %s\n", fp.TLS.JA3.Hash)
        fmt.Fprintf(w, "JA3 String: %s\n", fp.TLS.JA3.String)
    }
}
Enter fullscreen mode Exit fullscreen mode

The magic happens in WrapTLSConfig. It intercepts the TLS handshake, captures the ClientHello, and stores it keyed by remote address. The middleware then retrieves it and computes the full JA3.


How JA3 Computation Works

// Simplified from ja3.go
func ComputeJA3(hello *tls.ClientHelloInfo) *JA3Info {
    // Filter out GREASE values (RFC 8701)
    cipherSuites := filterGREASE(hello.CipherSuites)
    extensions := filterGREASE(hello.Extensions)
    curves := filterGREASE(hello.SupportedCurves)

    // Build JA3 string
    // Format: Version,Ciphers,Extensions,Curves,PointFormats
    ja3String := fmt.Sprintf("%d,%s,%s,%s,%s",
        hello.SupportedVersions[0],
        join(cipherSuites, "-"),
        join(extensions, "-"),
        join(curves, "-"),
        join(hello.SupportedPoints, "-"),
    )

    // MD5 hash for compatibility with original JA3
    hash := md5.Sum([]byte(ja3String))

    return &JA3Info{
        String: ja3String,
        Hash:   hex.EncodeToString(hash[:]),
    }
}
Enter fullscreen mode Exit fullscreen mode

GREASE values (0x0a0a, 0x1a1a, etc.) are filtered per the JA3 spec — they're random values browsers insert to test server compatibility.


Bot Detection in Practice

Here's a real-world pattern for protecting an API:

func protectedHandler(w http.ResponseWriter, r *http.Request) {
    fp, ok := reqdna.Get(r.Context())
    if !ok {
        http.Error(w, "Internal error", 500)
        return
    }

    // Hard block obvious bots
    if fp.IsBot() { // BotScore >= 0.7
        http.Error(w, "Access denied", 403)
        return
    }

    // Flag suspicious requests for review
    if fp.IsSuspicious() { // BotScore >= 0.4
        logSuspiciousRequest(r, fp)
    }

    // Use fingerprint for smarter rate limiting
    // Instead of just IP, use fp.Hash
    if !rateLimiter.Allow(fp.Hash) {
        http.Error(w, "Too many requests", 429)
        return
    }

    // Proceed with normal handling
    handleRequest(w, r)
}
Enter fullscreen mode Exit fullscreen mode

The key insight: fingerprint-based rate limiting is much harder to bypass than IP-based. Rotating IPs doesn't help when your TLS fingerprint stays the same.


Middleware Integration

Works with any router:

// Standard library
handler := reqdna.Middleware(mux)

// Chi
r := chi.NewRouter()
r.Use(reqdna.MiddlewareFunc())

// With JA3 (requires HTTPS)
r.Use(reqdna.MiddlewareFuncWithJA3(store))
Enter fullscreen mode Exit fullscreen mode

Performance

BenchmarkComputeJA3-12       815370    1411 ns/op    792 B/op   28 allocs
BenchmarkFromRequest-12      298992    3972 ns/op   1016 B/op   26 allocs
Enter fullscreen mode Exit fullscreen mode

~4μs per request. That's negligible compared to typical handler latency.


Privacy Considerations

By default, IP addresses are hashed with a salt before being included in the fingerprint. This means:

  • You can identify repeat visitors
  • You can't reverse the hash to get the original IP
  • GDPR-friendly by design
// Custom salt for your application
fp := reqdna.FromRequest(r,
    reqdna.WithHashSalt(os.Getenv("FINGERPRINT_SALT")),
)
Enter fullscreen mode Exit fullscreen mode

Testing Your Code

The library provides test helpers and interfaces for easy mocking:

func TestBotBlocking(t *testing.T) {
    // Create a bot fingerprint for testing
    fp := reqdna.TestBotFingerprint()

    if !fp.IsBot() {
        t.Error("should detect as bot")
    }
}

// Or use interfaces for dependency injection
type mockExtractor struct {
    fp reqdna.Fingerprint
}

func (m *mockExtractor) Extract(r *http.Request) reqdna.Fingerprint {
    return m.fp
}
Enter fullscreen mode Exit fullscreen mode

What's Next?

Some ideas I'm considering:

  • Known JA3 database — map hashes to known clients
  • Behavioral analysis — request timing patterns
  • WebSocket fingerprinting — extend to WS connections
  • Integration with kazrl — fingerprint-aware rate limiting out of the box

Conclusion

Bot detection is an arms race. Simple checks like User-Agent validation haven't been effective for years. But TLS fingerprinting with JA3 raises the bar significantly — bots now need to modify their TLS stack to evade detection.

reqdna gives you all of this with zero dependencies, ~4μs overhead, and a clean API. It's the "DNA" of HTTP requests.

Check it out: github.com/aqylsoft/reqdna


If you found this useful, I also wrote about building a zero-dependency rate limiter in Go. These two libraries work great together.

What fingerprinting techniques do you use for bot detection? Let me know in the comments!

Top comments (0)