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
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)
}
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
}
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:
SupportedVersionsCipherSuites-
Extensions(added in Go 1.21!) SupportedCurvesSupportedPoints
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)
}
}
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[:]),
}
}
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)
}
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))
Performance
BenchmarkComputeJA3-12 815370 1411 ns/op 792 B/op 28 allocs
BenchmarkFromRequest-12 298992 3972 ns/op 1016 B/op 26 allocs
~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")),
)
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
}
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)