Guides · WhiskerJS 0.1.0

Verify the anti-replay binding in Go

A standard-library Go package that issues nonces and checks that a WhiskerJS result is fresh and unchanged, plus a net/http server.

In plain words

Your Go server hands out a random one-time code with the time before the form is submitted. WhiskerJS seals that code into its result in the browser. When the form arrives, the server checks that it issued the code to this visitor, that it is unused and recent, and that the seal still matches every field of the result.

Install

Nothing to install on the server: the package below uses only the Go standard library (Go 1.22 or newer for the method patterns in net/http). The page side needs WhiskerJS built from source; it is not on npm yet, the npm package is coming. See the plain JavaScript guide.

The package

whiskerbind/whiskerbind.go rebuilds the text the browser hashed (the same as WhiskerJS’s bindingInput), keeps issued nonces, and verifies a result:

// Package whiskerbind checks the anti-replay binding of a WhiskerJS result
// (WhiskerJS 0.1.0, signalsVersion 9, idVersion 2). Standard library only.
package whiskerbind

import (
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"encoding/json"
	"errors"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"
)

// The versions this server ships. Ids and the binding format belong to them.
const (
	Version        = "0.1.0"
	SignalsVersion = "9"
	IDVersion      = "2"
	MaxAge         = 60 * time.Second
)

// ComponentNames are the 47 components of signalsVersion 9, sorted.
var ComponentNames = []string{
	"apple_pay", "audio", "audio_latency", "browser_class", "canvas", "canvas_readback",
	"color_depth", "color_gamut", "content_blockers", "cpu_architecture", "device_memory",
	"dynamic_range", "font_preferences", "fonts", "fonts_canvas", "forced_colors",
	"hardware_concurrency", "heap_limit", "intl_locale", "keyboard_layout", "languages", "math",
	"media_codecs", "noise_profile", "pdf_viewer", "permissions", "pixel_ratio", "platform", "plugins",
	"prefers_contrast", "private_click", "reduced_motion", "screen_frame", "screen_resolution",
	"speech_voices", "storage", "time_zone", "touch_support", "ua_client_hints", "ua_versions",
	"vendor", "vendor_flavors", "webgl_image", "webgl_params", "webgl_renderer", "webgl_vendor",
	"webgpu",
}

type Component struct {
	Status string  `json:"status"`
	Value  *string `json:"value,omitempty"`
	Hashed *bool   `json:"hashed,omitempty"`
	RuleID *string `json:"ruleId,omitempty"`
}

type Binding struct {
	Nonce     *string      `json:"nonce"`
	Timestamp *json.Number `json:"timestamp"`
	Digest    *string      `json:"digest"`
}

// Result is a WhiskerJS result. Numbers stay json.Number so that they print
// exactly as the browser printed them (never in exponent form).
type Result struct {
	VisitorID      *string              `json:"visitorId"`
	Version        string               `json:"version"`
	SignalsVersion string               `json:"signalsVersion"`
	IDVersion      string               `json:"idVersion"`
	Components     map[string]Component `json:"components"`
	StructuredID   *string              `json:"structuredId"`
	PrevSignals    *string              `json:"previousSignalsVersion"`
	PrevIDVersion  *string              `json:"previousIdVersion"`
	PrevVisitorID  *string              `json:"previousVisitorId"`
	PrevStructured *string              `json:"previousStructuredId"`
	IntegrityFlags []string             `json:"integrityFlags"`
	DurationMs     json.Number          `json:"durationMs"`
	Binding        *Binding             `json:"binding"`
}

var (
	ErrNoBinding      = errors.New("no_binding")
	ErrUnknownNonce   = errors.New("unknown_or_used_nonce")
	ErrWrongSession   = errors.New("wrong_session")
	ErrTimestamp      = errors.New("timestamp_mismatch")
	ErrExpired        = errors.New("expired")
	ErrVersion        = errors.New("unexpected_version")
	ErrComponents     = errors.New("unexpected_components")
	ErrBadShape       = errors.New("bad_shape")
	ErrDigestMismatch = errors.New("digest_mismatch")
)

var digestPattern = regexp.MustCompile(`^[0-9a-f]{32}$`)

func integer(n json.Number) (int64, error) {
	return strconv.ParseInt(string(n), 10, 64)
}

func orEmpty(s *string) string {
	if s == nil {
		return ""
	}
	return *s
}

func orBang(s *string) string {
	if s == nil {
		return "!"
	}
	return *s
}

// BindingInput rebuilds the exact text the browser hashed (bindingInput in
// WhiskerJS): one line per field, then one line per component, sorted by name.
func BindingInput(r *Result) (string, error) {
	duration, err := integer(r.DurationMs)
	if err != nil {
		return "", ErrBadShape
	}
	nonce, timestamp := "", ""
	if r.Binding != nil && r.Binding.Nonce != nil {
		nonce = *r.Binding.Nonce
	}
	if r.Binding != nil && r.Binding.Timestamp != nil {
		ts, err := integer(*r.Binding.Timestamp)
		if err != nil {
			return "", ErrBadShape
		}
		timestamp = strconv.FormatInt(ts, 10)
	}
	lines := []string{
		"whiskerjs/bind/2",
		"nonce=" + nonce,
		"timestamp=" + timestamp,
		"version=" + r.Version,
		"signalsVersion=" + r.SignalsVersion,
		"idVersion=" + r.IDVersion,
		"visitorId=" + orBang(r.VisitorID),
		"structuredId=" + orBang(r.StructuredID),
		"previousSignalsVersion=" + orBang(r.PrevSignals),
		"previousIdVersion=" + orBang(r.PrevIDVersion),
		"previousVisitorId=" + orBang(r.PrevVisitorID),
		"previousStructuredId=" + orBang(r.PrevStructured),
		"durationMs=" + strconv.FormatInt(duration, 10),
		"integrityFlags=" + strings.Join(r.IntegrityFlags, ","),
	}
	names := make([]string, 0, len(r.Components))
	for name := range r.Components {
		names = append(names, name)
	}
	sort.Strings(names)
	for _, name := range names {
		c := r.Components[name]
		kind := "-"
		if c.Hashed != nil && *c.Hashed {
			kind = "h"
		} else if c.Hashed != nil {
			kind = "v"
		}
		lines = append(lines, "c:"+name+"="+c.Status+"|"+kind+"|"+orEmpty(c.Value)+"|"+orEmpty(c.RuleID))
	}
	return strings.Join(lines, "\n"), nil
}

type issued struct {
	timestamp int64
	session   string
}

// Store keeps issued nonces. In production use your database or cache, with expiry.
type Store struct {
	mu     sync.Mutex
	nonces map[string]issued
}

func NewStore() *Store { return &Store{nonces: map[string]issued{}} }

// Remember records a nonce issued to a session.
func (s *Store) Remember(nonce string, timestamp int64, session string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.nonces[nonce] = issued{timestamp, session}
}

// Issue makes a single-use nonce (128 random bits) for this session.
func (s *Store) Issue(session string) (string, int64, error) {
	buf := make([]byte, 16)
	if _, err := rand.Read(buf); err != nil {
		return "", 0, err
	}
	nonce := hex.EncodeToString(buf)
	timestamp := time.Now().UnixMilli()
	s.Remember(nonce, timestamp, session)
	return nonce, timestamp, nil
}

// take returns and deletes a nonce in one step, so it can be used only once.
func (s *Store) take(nonce string) (issued, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	entry, ok := s.nonces[nonce]
	delete(s.nonces, nonce)
	return entry, ok
}

// Verify checks the binding of a result this session sent. It returns nil
// when the nonce is ours, unused and recent and the digest matches.
func (s *Store) Verify(r *Result, session string, now time.Time) error {
	if r == nil || r.Binding == nil || r.Binding.Nonce == nil || r.Binding.Timestamp == nil ||
		r.Binding.Digest == nil || !digestPattern.MatchString(*r.Binding.Digest) {
		return ErrNoBinding
	}
	entry, ok := s.take(*r.Binding.Nonce)
	if !ok {
		return ErrUnknownNonce
	}
	if entry.session != session {
		return ErrWrongSession
	}
	ts, err := integer(*r.Binding.Timestamp)
	if err != nil || ts != entry.timestamp {
		return ErrTimestamp
	}
	if now.UnixMilli()-entry.timestamp > MaxAge.Milliseconds() {
		return ErrExpired
	}
	if r.Version != Version || r.SignalsVersion != SignalsVersion || r.IDVersion != IDVersion {
		return ErrVersion
	}
	if len(r.Components) != len(ComponentNames) {
		return ErrComponents
	}
	for _, name := range ComponentNames {
		if _, ok := r.Components[name]; !ok {
			return ErrComponents
		}
	}
	text, err := BindingInput(r)
	if err != nil {
		return err
	}
	sum := sha256.Sum256([]byte(text))
	expected := hex.EncodeToString(sum[:])[:32]
	if subtle.ConstantTimeCompare([]byte(expected), []byte(*r.Binding.Digest)) != 1 {
		return ErrDigestMismatch
	}
	return nil
}

Points that matter for an exact match with the browser:

  • Numbers. timestamp and durationMs are decoded as json.Number, parsed with strconv.ParseInt and printed with strconv.FormatInt. Decoding them into float64 and printing with %v could give exponent form, and the digest would not match.
  • Missing versus empty. Value, Hashed and RuleID are pointers, so an absent hashed prints - and hashed: false prints v.
  • The previous ids. Since whiskerjs/bind/2 the four fields previousSignalsVersion, previousIdVersion, previousVisitorId and previousStructuredId follow structuredId; each prints ! when null (all four are null in a release that computes no previous ids).
  • Sorting. sort.Strings orders names by bytes, as JavaScript’s default sort does for these ASCII names.
  • Single use. take reads and deletes a nonce under one lock, so two parallel requests cannot both use it.
  • Constant time. subtle.ConstantTimeCompare compares the digests.

A complete server

cmd/server/main.go: a random sid cookie as the session (use yours if you have one), the nonce endpoint, and the login post that verifies the binding before anything else. The file server at the end only serves the example page.

package main

import (
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"net/http"
	"time"

	"example.com/whiskerdemo/whiskerbind"
)

var store = whiskerbind.NewStore()

// session returns this browser's session id, setting a cookie the first time.
func session(w http.ResponseWriter, r *http.Request) string {
	if c, err := r.Cookie("sid"); err == nil && len(c.Value) == 32 {
		return c.Value
	}
	buf := make([]byte, 16)
	rand.Read(buf)
	sid := hex.EncodeToString(buf)
	http.SetCookie(w, &http.Cookie{Name: "sid", Value: sid, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode})
	return sid
}

func send(w http.ResponseWriter, status int, body any) {
	w.Header().Set("content-type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(body)
}

func main() {
	http.HandleFunc("GET /api/whisker-nonce", func(w http.ResponseWriter, r *http.Request) {
		nonce, timestamp, err := store.Issue(session(w, r))
		if err != nil {
			send(w, 500, map[string]string{"error": "no_nonce"})
			return
		}
		send(w, 200, map[string]any{"nonce": nonce, "timestamp": timestamp})
	})
	http.HandleFunc("POST /api/login", func(w http.ResponseWriter, r *http.Request) {
		var body struct {
			Email    string              `json:"email"`
			Password string              `json:"password"`
			Device   *whiskerbind.Result `json:"device"`
		}
		r.Body = http.MaxBytesReader(w, r.Body, 64<<10) // a result is about 4 KB
		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
			send(w, 400, map[string]string{"error": "bad_request"})
			return
		}
		if err := store.Verify(body.Device, session(w, r), time.Now()); err != nil {
			send(w, 400, map[string]string{"error": err.Error()})
			return
		}
		// Fresh and unchanged since collection. Now check the password and use
		// body.Device as one piece of evidence among others.
		send(w, 200, map[string]any{"visitorId": body.Device.VisitorID, "flags": body.Device.IntegrityFlags})
	})
	// Static files for this example only; your app serves its own.
	http.Handle("GET /", http.FileServer(http.Dir("public")))
	http.ListenAndServe("localhost:3000", nil)
}

store keeps nonces in memory, which only works for one process. With several instances, put them in your database or a shared cache with an expiry, and delete each one in the same step that reads it.

What the digest proves

The binding digest is the first 128 bits of SHA-256 over a fixed text built from the whole result, your nonce and your timestamp. When the server rebuilds that text from what it received and gets the same digest, and the nonce is one it issued to this session, unused and recent, then:

  • it proves the result was made in a browser that held your nonce, within the time window, and was not changed afterwards (a changed value, status, flag or id gives a different digest);
  • it stops replay: a recorded result cannot be sent again, because its nonce is used up, and an old one fails the age check;
  • it does not prove that the browser told the truth. The digest is not a signature: code in the page can compute it too, so an attacker who controls the browser can build a fake result for a fresh nonce. Signing belongs on your server, where the key is safe.

So a passing binding means “fresh and unchanged”, nothing more. Use the result as evidence among other evidence; see what never to trust from the client.

The checks, in order

Step Check Refusal
1 binding exists with a string nonce and a 32-hex digest no_binding
2 the nonce was issued by this server, is unused, and is deleted now, before any slow work unknown_or_used_nonce
3 the nonce was issued to this session wrong_session
4 the timestamp equals the one stored with the nonce (never trust the one in the result) timestamp_mismatch
5 issued at most 60 seconds ago (tune to your form) expired
6 version, signalsVersion, idVersion are the ones you ship unexpected_version
7 components has exactly the 47 names of signalsVersion 9 unexpected_components
8 the rebuilt text hashes to the received digest, compared in constant time digest_mismatch

Deleting the nonce in step 2, before anything else, means two parallel requests with the same result cannot both pass.

The text that is hashed

The browser hashes these lines joined with \n (UTF-8), and the server rebuilds them from the result it received:

whiskerjs/bind/2
nonce=<nonce>
timestamp=<timestamp>
version=<version>
signalsVersion=<signalsVersion>
idVersion=<idVersion>
visitorId=<visitorId, or ! when null>
structuredId=<structuredId, or ! when null>
previousSignalsVersion=<previousSignalsVersion, or ! when null>
previousIdVersion=<previousIdVersion, or ! when null>
previousVisitorId=<previousVisitorId, or ! when null>
previousStructuredId=<previousStructuredId, or ! when null>
durationMs=<durationMs>
integrityFlags=<flags joined with ,>
c:<name>=<status>|<h, v or ->|<value or empty>|<ruleId or empty>

The last line repeats once per component, names sorted (byte order). The middle field is h for hashed: true, v for hashed: false and - when hashed is absent. timestamp and durationMs are integers and must print as plain digits. The format is described in Anti-replay binding.

Tested

Tested with WhiskerJS 0.1.0 (signalsVersion 9, idVersion 2, binding text whiskerjs/bind/2) on 2026-10-26:

  • the package with Go 1.27.1 (go vet and gofmt clean) against a result made by the library at commit 3618012 (in Node 22 with the library’s own test browser, with a binding and previous ids): accepted; the same result again refused with unknown_or_used_nonce; a changed previousVisitorId refused with digest_mismatch; a check 61 seconds after issue refused with expired;
  • the server, built for linux/arm64 with Go 1.27.1, with the plain JavaScript page in headless Chromium 140 (Playwright 1.55.0), at signalsVersion 5 (binding text whiskerjs/bind/1): the login was accepted, and posting the same result again was refused with unknown_or_used_nonce. The server code is unchanged since; only the package changed.

Questions and answers

Why json.Number instead of float64 or int64 fields?

Decoding into float64 and printing it can produce exponent form for large values such as a millisecond timestamp, which would not match the text the browser hashed. json.Number keeps the digits; the package then parses them as int64 and refuses anything that is not a plain integer.

Why do Value, Hashed and RuleID use pointers?

The text that is hashed distinguishes a missing field from an empty one: hashed absent gives '-', hashed false gives 'v'. Pointers keep that difference.

Do I have to update the component list?

Yes, when you ship a WhiskerJS version with a new signalsVersion. The list and the version constants belong to the library version your pages load; a mismatch is refused as unexpected_version or unexpected_components.