# 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.

Source: https://whiskerjs.com/guides/verify-go/ (last updated 2026-10-26)

> **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](https://whiskerjs.com/guides/javascript/#install-build-from-source).

## The package

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

```go
// 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.

```go
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](https://whiskerjs.com/docs/integration/#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:

```text
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](https://whiskerjs.com/docs/integration/#the-digest).

## 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](https://whiskerjs.com/guides/javascript/) 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.

## Related pages

- [Verify in Node](https://whiskerjs.com/guides/verify-node/): The same checks using the library's own code.
- [Verify in Python](https://whiskerjs.com/guides/verify-python/): The same checks in Python.
- [Plain JavaScript](https://whiskerjs.com/guides/javascript/): The page this server was tested with.
- [Anti-replay binding](https://whiskerjs.com/docs/integration/#anti-replay-binding): The format in detail.
