The visitor ID your JavaScript agent produces is only as trustworthy as the place you verify it. If your Go backend accepts whatever ID the browser posts, an attacker simply posts a known-good ID and walks past every check you built. Verification has to happen server-side, against a source the client cannot forge, and Go’s standard library gives you everything needed to do it cleanly.
This article shows how to verify fingerprints in Go: validating sealed results, checking freshness and integrity, avoiding the client-trust trap, and wiring the result into an HTTP handler. It complements the Node server-side verification guide and the SDKs overview.
The client-trust trap
Every field a browser sends is attacker-controlled. The visitor ID, the confidence score, the smart-signal flags, all of it arrives over a channel the client fully controls and can replay, mutate, or fabricate. The single most common integration bug is treating the client-reported ID as authoritative.
The failure looks like this:
// WRONG: trusts whatever the browser claims
func handler(w http.ResponseWriter, r *http.Request) {
visitorID := r.Header.Get("X-Visitor-Id") // attacker sets this freely
if isTrusted(visitorID) {
approve() // bypassed by anyone who knows a trusted ID
}
}
The fix is to never trust a bare ID off the wire. Either look it up against a server-side record you created, or verify a cryptographically sealed result that the client cannot have tampered with. The rest of this article takes the sealed-result path, because it needs no external round-trip and keeps the sensitive data on your infrastructure, which is the whole point of self-hosting for data residency.
Verifying a sealed result
A sealed result is the fingerprinting payload encrypted and authenticated with a key only your backend holds. Your Go server decrypts it, checks the signature, and only then reads the visitor ID and signals inside. Because the client never had the key, it could not have forged the contents.
The verification steps, in order:
- Decode the sealed blob from its transport encoding.
- Verify integrity with the message authentication tag before decrypting.
- Decrypt to recover the plaintext result.
- Check freshness against the embedded timestamp.
- Bind the result to the current request context.
package fp
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"errors"
"time"
)
type Result struct {
VisitorID string `json:"visitorId"`
Confidence float64 `json:"confidence"`
Signals Signals `json:"signals"`
IssuedAt time.Time `json:"issuedAt"`
}
var ErrStale = errors.New("fingerprint result too old")
func Verify(sealed string, key []byte, maxAge time.Duration) (*Result, error) {
raw, err := base64.StdEncoding.DecodeString(sealed)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block) // GCM verifies the auth tag on Open
if err != nil {
return nil, err
}
ns := gcm.NonceSize()
if len(raw) < ns {
return nil, errors.New("malformed sealed result")
}
nonce, ct := raw[:ns], raw[ns:]
plain, err := gcm.Open(nil, nonce, ct, nil) // fails if tampered
if err != nil {
return nil, err
}
var res Result
if err := json.Unmarshal(plain, &res); err != nil {
return nil, err
}
if time.Since(res.IssuedAt) > maxAge {
return nil, ErrStale
}
return &res, nil
}
The crucial detail is that gcm.Open returns an error if the ciphertext or tag was altered. Integrity and confidentiality come from the same primitive, so a tampered payload never reaches your JSON parser.
Freshness and replay defense
Decryption proves the payload is authentic, but not that it is current. An attacker who captures a valid sealed result could replay it later. Freshness and binding close that gap.
- Freshness window. Reject results older than a short window, seconds to a couple of minutes depending on your flow. The
maxAgecheck above enforces this. - Nonce or request binding. Include a server-issued nonce in the client call and verify it matches, so a result is valid only for the request it was generated for.
- Single use for sensitive actions. For a login or payment, record the result identifier and refuse a second use.
| Threat | Defense |
|---|---|
| Forged visitor ID | Sealed result signature (GCM tag) |
| Tampered signals | Authenticated decryption |
| Replayed old result | Freshness window on IssuedAt |
| Cross-request reuse | Server nonce binding |
Wiring it into a handler
With verification isolated in one function, your handlers stay readable and every protected route runs the same gate. Pull the decryption key from your secret manager, never from source.
func ProtectLogin(key []byte) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sealed := r.Header.Get("X-Sealed-Result")
res, err := fp.Verify(sealed, key, 90*time.Second)
if err != nil {
http.Error(w, "fingerprint verification failed", http.StatusUnauthorized)
return
}
if res.Confidence < 0.5 || res.Signals.Bot {
stepUp(w, r) // challenge low-confidence or bot sessions
return
}
proceedLogin(w, r, res.VisitorID)
}
}
Notice the decision uses the verified res, never a client header. The visitor ID that drives your login protection or rate limiting by device now comes only from a payload your server authenticated.
Operational notes
A few practices keep a Go integration healthy in production:
- Rotate keys on a schedule and support two active keys during rollover so in-flight results still verify.
- Fail closed for high-risk actions and fail open for low-risk ones, so a verification outage degrades gracefully rather than locking everyone out.
- Log reason codes, not raw payloads, feeding explainable decisions without storing sensitive fingerprint data longer than needed.
- Keep verification stateless where possible so it scales horizontally behind a load balancer.
Because everything runs on your own infrastructure, you decide retention, jurisdiction, and logging, which is the practical advantage of an open-source, self-hosted approach over a vendor black box.
Frequently asked questions
Why verify fingerprints server-side in Go instead of trusting the client?
Anything the browser sends can be forged, so a visitor ID must be validated against a trusted source or cryptographically sealed result on your server before it influences a decision.
What are sealed results?
Sealed results are the fingerprinting payload encrypted and signed by the agent so your Go backend can decrypt and verify them without a round-trip to an external service.
How do you prevent replay of a captured visitor ID?
Check the result timestamp for freshness, bind it to the request context, and reject payloads older than a short window or reused across sessions.
Server-side verification is the line between a fingerprint that protects you and one an attacker replays. Verify sealed results with authenticated decryption, enforce freshness, bind to the request, and drive decisions only from the verified payload. See the docs for the sealed-result format and the SDKs page for the matching client agent.
Run it yourself
Prynt is open-source, self-hostable device intelligence — visitor IDs, bot & fraud Smart Signals, and behavioral biometrics you own end to end.