Go Fraud Detection Middleware, Without an SDK

No Go SDK needed for a one-endpoint API. A net/http middleware with a real timeout, fail-open behaviour and a circuit breaker, in about seventy lines.

Two players' hands over a Go board on a street table, one holding a black stone, the other a white one
Photo: Playing weiqi in Shanghai by Brian Jeffery Beggerly from S'pore, Singapore, CC BY 2.0, cropped. Via Wikimedia Commons.
On this page
  1. Step 1: Add the script to the page with your form
  2. Step 2: Send the check from your server
  3. The middleware
  4. Using it
  5. Step 3: Submit your form once
  6. A breaker for the bad afternoon
  7. Testing without an account
  8. Before it ships

There is no Go SDK for Maskbreak. There are Node, Python and PHP packages, and Go is conspicuously absent from that list, which comes up often enough that it is worth answering properly.

The honest answer: for a one-endpoint JSON API, you do not want one. The wrapper is about seventy lines of standard library, it has no third-party dependencies, and writing it yourself means the timeout and the failure mode are decisions you made rather than ones you inherited. This follows the three steps the Maskbreak console lists (add the script to the page with your form, send the check from your server, submit your form once) and starts in watch mode, where every check is logged and nothing is refused.

Step 1: Add the script to the page with your form

Load the script on the page with your form and put class="monocle-enriched" on the <form> element itself (never on an input):

html
<!-- templates/signup.html -->
<script async src="https://maskbreak.com/assets/sentinel.js"></script>

<form method="post" action="/signup" class="monocle-enriched">
  <input type="email" name="email" required>
  <input type="password" name="password" required>
  <button type="submit">Create account</button>
</form>

The script adds three hidden fields: monocle (the network token), sentinel_fp (the browser check id) and sentinel_tz (the time zone), so they arrive in r.FormValue. A JavaScript frontend calls await window.Sentinel.collect() instead (after checking that window.Sentinel exists; submit anyway when it does not) and posts its token, fingerprintEventId and tz as form fields alongside its own. If you set a Content-Security-Policy, add https://maskbreak.com, https://mcl.maskbreak.com, https://*.mcl.maskbreak.com, https://mesu.maskbreak.com and 'wasm-unsafe-eval' to script-src, the mcl hosts (https and wss) and mesu to connect-src, and https://mesu.maskbreak.com blob: to worker-src.

Step 2: Send the check from your server

One POST, one JSON body, one bearer token. The client sends whatever evidence arrived, including none: a check without a token or a browser check id is how the console tells you which half is missing.

go
package fraud

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"
)

// "watch" (default) or "enforce".
var Mode = func() string {
	if m := os.Getenv("MASKBREAK_MODE"); m != "" {
		return m
	}
	return "watch"
}()

type Client struct {
	Key  string
	HTTP *http.Client
}

func New(key string) *Client {
	// One shared client keeps connections alive: a fresh TLS handshake
	// per signup is self-inflicted latency.
	return &Client{Key: key, HTTP: &http.Client{Timeout: 3 * time.Second}}
}

type Request struct {
	Token              string `json:"token"`
	FingerprintEventID string `json:"fingerprintEventId,omitempty"`
	TZ                 string `json:"tz,omitempty"`
	Email              string `json:"email,omitempty"`
	AccountID          string `json:"accountId,omitempty"`
}

type Verdict struct {
	Decision       string   `json:"decision"` // allow | review | block
	RiskScore      int      `json:"risk_score"`
	Reasons        []string `json:"reasons"`
	DeviceEvidence string   `json:"device_evidence"` // ok | not_sent | unavailable
	Degraded       bool     `json:"degraded"`
	Test           bool     `json:"test"`
}

// CleanAllow is a complete, production allow: what a strict action needs.
func (v *Verdict) CleanAllow() bool {
	return v != nil && v.Decision == "allow" && v.DeviceEvidence == "ok" && !v.Degraded && !v.Test
}

func (c *Client) Evaluate(ctx context.Context, in Request) (*Verdict, error) {
	body, err := json.Marshal(in)
	if err != nil {
		return nil, err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		"https://maskbreak.com/v1/evaluate", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+c.Key)
	req.Header.Set("Content-Type", "application/json")

	res, err := c.HTTP.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	if res.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("maskbreak: status %d", res.StatusCode) // 401: the key
	}
	var v Verdict
	if err := json.NewDecoder(res.Body).Decode(&v); err != nil {
		return nil, err
	}
	return &v, nil
}
Try it

Wire it into your own app: a free key returns decision, risk_score and reasons for every visit, 10,000 checks a month on the Free plan, no card.

Get an API key

The middleware

The verdict belongs in the request context so the handler decides what to do with it. The middleware never refuses a request itself, and it calls on every request it wraps, even when the form carried no token.

go
// guard.go, in the same package
package fraud

import (
	"context"
	"expvar"
	"log"
	"net/http"
	"time"
)

// Failed calls, graphable from /debug/vars or your metrics exporter.
var evalErrors = expvar.NewInt("maskbreak_eval_errors")

type ctxKey struct{}

// Guard evaluates the visit and stashes the verdict. It never refuses a
// request itself: allow/review/block is a product decision, and the same
// middleware has to serve a signup form and a payout endpoint.
func (c *Client) Guard(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Its own deadline: a slow fraud check must not consume the
		// budget the handler needs to answer.
		ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
		defer cancel()

		_ = r.ParseForm()
		v, err := c.Evaluate(ctx, Request{
			Token:              first(r, "monocle", "token"),
			FingerprintEventID: first(r, "sentinel_fp", "fingerprintEventId"),
			TZ:                 first(r, "sentinel_tz", "tz"),
			Email:              r.FormValue("email"),
		})
		if err != nil {
			evalErrors.Add(1) // fail open, and make the failure countable
			log.Printf("maskbreak unavailable: %v", err)
		}
		// One line per check. Never log the key, the token or form data.
		if v != nil {
			log.Printf("maskbreak mode=%s decision=%s reasons=%v device_evidence=%s degraded=%t",
				Mode, v.Decision, v.Reasons, v.DeviceEvidence, v.Degraded)
		}
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, v)))
	})
}

func first(r *http.Request, a, b string) string {
	if v := r.FormValue(a); v != "" {
		return v
	}
	return r.FormValue(b)
}

// FromContext returns the verdict, or nil when the check did not complete.
func FromContext(ctx context.Context) *Verdict {
	v, _ := ctx.Value(ctxKey{}).(*Verdict)
	return v
}

The nil return is the contract, and handlers have to respect it. FromContext returning nil means the check did not complete, not that the visit was clean.

Using it

go
mux := http.NewServeMux()
guard := fraud.New(os.Getenv("MASKBREAK_API_KEY")).Guard

// Mounted per action, not globally.
mux.Handle("/signup", guard(http.HandlerFunc(signup)))
mux.Handle("/payout", guard(http.HandlerFunc(payout)))

func signup(w http.ResponseWriter, r *http.Request) {
	v := fraud.FromContext(r.Context())
	if fraud.Mode == "enforce" && v != nil {
		switch v.Decision {
		case "block":
			http.Error(w, "unable to complete signup", http.StatusForbidden)
			return
		case "review":
			requireEmailOTP(r) // step up, do not refuse
		}
	}
	createAccount(w, r) // watch mode: always reached
}

func payout(w http.ResponseWriter, r *http.Request) {
	v := fraud.FromContext(r.Context())
	if fraud.Mode == "enforce" && !v.CleanAllow() {
		http.Error(w, "additional verification required", http.StatusConflict)
		return
	}
	sendPayout(w, r)
}

Mounting per action rather than on the whole mux matters more in Go than in frameworks where middleware registration is centralised and invisible. Wrapping every route multiplies call volume, adds latency to endpoints that gain nothing, and converts a vendor slowdown into a whole-site problem instead of a checkout problem. Set MASKBREAK_API_KEY to your live key in the environment; it is never compiled in and never shipped to a browser, including via WebAssembly.

Step 3: Submit your form once

Run the app on your computer or deploy it, open the page in an ordinary browser, wait a moment, and submit the form. The first check then appears in the Maskbreak console (if half of it did not arrive, the console says which half), and your log shows one maskbreak line. degraded=true means no network token arrived (the script was blocked, or the class is missing or on an input); device_evidence=not_sent means no browser check id arrived; device_evidence=ok with degraded=false is a complete check. When the lines look right after a few days, set MASKBREAK_MODE=enforce and restart. A fixed test token such as test_clean comes back with test: true, which is why CleanAllow refuses it on a payout.

A breaker for the bad afternoon

Failing open on a single timeout is correct. Failing open on ten thousand consecutive timeouts, each costing seconds of a request handler, is a queue backing up. A few lines fix it:

go
// breaker.go, same package (imports "sync" and "time")
type breaker struct {
	mu     sync.Mutex
	fails  int
	openTo time.Time
}

func (b *breaker) allow() bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	return time.Now().After(b.openTo)
}

func (b *breaker) record(err error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	if err == nil {
		b.fails = 0
		return
	}
	if b.fails++; b.fails >= 5 {
		b.openTo = time.Now().Add(30 * time.Second) // skip the call entirely
		b.fails = 0
	}
}

To wire it in, give Client a brk breaker field; in Guard, skip the Evaluate call (and leave the verdict nil) while c.brk.allow() is false, and pass the call's error to c.brk.record afterwards. Five consecutive failures and the middleware stops calling out for thirty seconds, letting traffic through with no verdict at all. You lose detection for half a minute. You do not lose the site.

Testing without an account

The sandbox key is public and returns fixed decisions for the test tokens, so integration tests need no account:

go
func TestVerdictShapes(t *testing.T) {
	c := fraud.New("sk_test_sandbox")
	for token, want := range map[string]string{
		"test_clean": "allow",
		"test_vpn":   "review",
		"test_proxy": "block",
		"test_tor":   "block",
	} {
		v, err := c.Evaluate(context.Background(), fraud.Request{Token: token})
		if err != nil {
			t.Fatalf("%s: %v", token, err)
		}
		if v.Decision != want {
			t.Errorf("%s: got %q want %q", token, v.Decision, want)
		}
		if !v.Test {
			t.Errorf("%s: a sandbox answer must say test", token)
		}
	}
}

Route on Decision, never on RiskScore: the score is a magnitude, the decision is the verdict, and your dashboard rules change only the decision. There is a longer argument about why.

Before it ships

  • Script on the page, the class on the <form>, and token, fingerprintEventId and tz forwarded on every call.
  • Key read from the environment, never compiled in, never shipped to a browser.
  • One shared http.Client, not one per request, with a timeout.
  • Every error path continues and increments a counter you actually graph.
  • Watch first. In enforce mode, review steps up; it does not route to the same branch as block.

Questions people ask

Is there an official Go SDK?
Not today. The published SDKs are Node, Python and PHP. For a single-endpoint JSON API a Go wrapper is about seventy lines of net/http with no third-party dependencies, and writing it yourself means the timeout, the retry policy and the failure mode are decisions you made rather than ones you inherited.
Should the middleware run on every route?
No. Mount it on the handful of actions where abuse actually costs money: signup, login, checkout, payout, promo redemption. Wrapping every route multiplies your call volume, adds latency to endpoints that gain nothing from it, and makes a vendor slowdown a whole-site problem instead of a checkout problem.
What happens if the API times out?
The request is allowed. That is deliberate: a fraud check that fails closed turns a vendor incident into an outage of your own signup or checkout, which is nearly always the more expensive failure. The middleware in this post uses a three-second context deadline, allows on any error, and increments a counter so a silent degradation still shows up in your metrics.
Where does the token come from in a Go application?
From the browser script, which adds three hidden inputs to a form that carries class "monocle-enriched": monocle for the network layer, sentinel_fp for the device event id and sentinel_tz for the time zone. Your handler reads them off the parsed form and forwards them as token, fingerprintEventId and tz. Neither value is a secret and neither is trusted on its own: Maskbreak resolves both server-side before any verdict exists.
Does the API key ever belong in client-side Go or WASM?
No. A live key authenticates your account and belongs only in server-side code, read from the environment. Anything compiled to WebAssembly and shipped to a browser is client-side code, whatever language it started as, and a key embedded there is a key you have published.

Paste it in, then watch the verdicts

The public sk_test_sandbox key returns the documented allow, review and block shapes with no account, so the failure path is testable before you go live. SDKs for Node, Python and PHP, or plain HTTP. The API reference and the Free plan cover the rest.

Get started freeRead the API docs