Guide

How to stop duplicate signups in a Go application

Alex MugoFounder, Kaidn
7 min read
Build a Go signup flow that recognises a returning device and refuses a second free trial, using the kaidn-go client. Full working code and demo repo.

Free trials get farmed. Somebody signs up, uses the fourteen days, then signs up again with a fresh email and keeps going indefinitely. If your product costs real money to run, that is margin walking out of the door in a way that never shows up as fraud in any dashboard, because every one of those accounts looks completely normal on its own.

Blocking duplicate email addresses does not work. Gmail ignores dots and anything after a plus sign, so one inbox is an unlimited supply of addresses. Blocking by IP does not work either, and it is actively harmful: under CGNAT thousands of unrelated mobile users share one address, so you would be punishing an entire carrier's customers for one person.

What does work is recognising the device, then treating that recognition as evidence rather than proof.

This article builds a working Go signup flow that does it. Standard library only, no dependencies, about 200 lines. By the end you will have an app that allows a genuine signup, holds an uncertain one, and refuses a farmed one, with the reasoning attached to every decision.

Prerequisites#

  • Go 1.21 or later. go version to check.
  • A Kaidn account. The free tier is 10,000 events a month with no card, which is far more than this tutorial needs.
  • Two keys from your dashboard: a publishable key beginning kdn_pub_ for the browser, and a secret key for your server.

Create the Go application#

Terminal
mkdir kaidn-go-demo && cd kaidn-go-demo
go mod init github.com/you/kaidn-go-demo
go get github.com/kaidn-io/kaidn-go
mkdir templates

The client has no dependencies. The whole API is JSON over HTTP, so a dependency there would buy nothing and cost you a supply chain to audit.

The whole integration, in one call#

main.go
package main

import (
	"log"
	"net/http"
	"os"
	"time"

	kaidn "github.com/kaidn-io/kaidn-go"
)

func main() {
	client := kaidn.New(kaidn.Options{
		APIKey: os.Getenv("KAIDN_SECRET_KEY"),
		// Short on purpose: scoring sits in the critical path of a signup, and
		// a fraud check must never be the reason a real customer cannot register.
		Timeout: 3 * time.Second,
	})
	...
}

Scoring an event is then one method:

main.go
res, err := client.Score(r.Context(), kaidn.Event{
	Event:    "signup",
	UserID:   email,
	Email:    email,
	IP:       kaidn.ClientIP(r),
	DeviceID: deviceID,
})

Only Event is required. Everything else is a signal, and each one you send is another check that can fire. Sending just an email still returns a verdict, it just has less to go on. There is no penalty for omitting a field you do not have, and a real penalty for inventing one: a fabricated IP gets scored as though it were real.

Build the signup page#

Create templates/signup.html. The important part is one hidden field and the module at the bottom:

templates/signup.html
<form id="signup" method="POST" action="/signup">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required>

  <label for="password">Password</label>
  <input id="password" name="password" type="password" required>

  <!-- @kaidn/fp fills this in before the form can submit -->
  <input id="kaidn_device_id" name="kaidn_device_id" type="hidden">

  <button type="submit" id="submit" disabled>Identifying device…</button>
</form>

<script type="module">
  const btn   = document.getElementById('submit');
  const field = document.getElementById('kaidn_device_id');

  // Never let the collector hold the form hostage.
  const withTimeout = (p, ms) =>
    Promise.race([p, new Promise((_, rej) =>
      setTimeout(() => rej(new Error('timeout')), ms))]);

  try {
    const { load } = await withTimeout(import('https://esm.sh/@kaidn/fp'), 4000);
    const kaidn = await withTimeout(load({ publishableKey: {{.PublicKey}} }), 4000);
    const { deviceId } = await withTimeout(kaidn.get(), 4000);
    field.value = deviceId;
  } catch (err) {
    // Fail open: a blocked, slow or broken collector costs you a signal,
    // never a signup. The server still scores the IP and the email.
    console.warn('kaidn collector unavailable:', err.message);
  } finally {
    btn.disabled = false;
    btn.textContent = 'Create account';
  }
</script>

Two details there are load-bearing, and I got both wrong in the first version of this demo.

The import is dynamic, not static. A static import at the top of a module throws before any of your code runs, so a try/catch further down never sees it and the button stays disabled forever. And every step races a timeout, because the failure that actually bites is not an error, it is a promise that never settles either way.

The button then enables in finally, so it enables whether the collector succeeded, failed, or hung. Ad blockers and privacy extensions block fingerprinting scripts routinely. A form that stays disabled when the collector is blocked is a form silently losing you customers who did nothing wrong.

The signup form after the collector failed to load. The button is enabled anyway and the page says so: scoring falls back to IP and email

That screenshot is the failure case on purpose. It is what a visitor with uBlock Origin sees, and the only correct response to it is the one shown: carry on.

Handle the error before the verdict#

main.go
	// FAIL OPEN. If the fraud service is unreachable or slow, allow the signup
	// and flag it. An outage in a vendor must never become an outage in your
	// product. This is the most important branch in the file.
	if err != nil {
		log.Printf("kaidn unavailable, allowing signup: %v", err)
		a.store.add(email, deviceID)
		a.render(w, "result.html", result{
			OK:      true,
			Heading: "Account created",
			Detail:  "The fraud check could not be reached, so the signup was allowed and flagged.",
		})
		return
	}

The client already retried the transient failures twice with backoff, honouring your context, so an error reaching this line means the service is genuinely unreachable. If you need to tell the cases apart:

go
var kerr *kaidn.Error
if errors.As(err, &kerr) {
	switch {
	case kerr.QuotaExceeded(): // 429, out of events for the period
	case kerr.Retryable():     // network or 5xx, worth queueing
	default:                   // 4xx, bad input or bad key, will not improve
	}
}

Act on the verdict#

Three outcomes, not two. This is where most integrations go wrong.

main.go
switch res.Verdict {
case kaidn.VerdictBlock:
	a.render(w, "result.html", result{
		Heading:    "We could not create that account",
		Detail:     "This signup matched an account we have already seen.",
		Score:      res.Score,
		Verdict:    res.Verdict,
		ReasonText: res.ReasonText,
		Checks:     res.Checks,
	})

case kaidn.VerdictReview:
	// Create the account, withhold the thing worth stealing, settle it later.
	a.store.add(email, deviceID)
	a.render(w, "result.html", result{
		OK:      true,
		Heading: "Account created, trial pending",
		Detail:  "The account exists but the free trial is held until reviewed.",
		Score:   res.Score, Verdict: res.Verdict,
		ReasonText: res.ReasonText, Checks: res.Checks,
	})

default: // allow
	a.store.add(email, deviceID)
	a.render(w, "result.html", result{
		OK: true, Heading: "Account created", Detail: "Free trial activated.",
		Score: res.Score, Verdict: res.Verdict,
		ReasonText: res.ReasonText, Checks: res.Checks,
	})
}

Test it#

Terminal
export KAIDN_SECRET_KEY="kdn_live_…"
export KAIDN_PUBLISHABLE_KEY="kdn_pub_…"
go run .

Open http://localhost:8080 and sign up normally. Nothing fires, score 0, account created.

A clean signup: nothing fires, the account is created

Now sign up again from the same browser with a disposable address. The device is recognised, the email domain is recognised, and the two stack:

A farmed signup: four signals fire, score 95, blocked

Read the weights, because they are the interesting part:

SignalReason codeWeight
Datacenter IP, not a residential userdatacenter_ip45
Device linked to 3 accounts on different networksdevice_reuse15
Disposable email domaindisposable_email35
Email domain on a known-abusive listabusive_email_domain30

The number worth arguing about#

device_reuse is 15. The signal this whole article is about carries the smallest weight on the page. That is deliberate, and it is the part most fingerprinting tutorials will not tell you.

A raw browser fingerprint is a hash of settings, and identical settings produce identical hashes. A default iPhone genuinely does match another default iPhone. On our own traffic one raw fingerprint covers 2.30 real people on iOS Safari and 1.53 across all traffic. If a device match alone could block a signup, you would be refusing strangers, and the refusals would cluster almost entirely on one platform. Your dashboard would say "iPhone users commit more fraud" when reality said "iPhone users share more fingerprints."

So the device is evidence, weighted to matter only in company. Corroborate it with the network and it sharpens considerably: adding the ASN takes iOS Safari from 2.30 people per identity to 1.27. Promote it to a first-party device token you issue yourself and it reaches about 1.01, at which point it really is one browser. The mechanics are in the device identity docs, and the fuller argument is in affiliate fraud.

Conclusion#

Under 200 lines of application code, one method call, and a signup flow that tells the difference between a customer and a second free trial. One dependency, which itself has none.

The parts worth keeping when you adapt this: fail open on error, read the real client IP, act on three outcomes rather than two, and never let a device match decide anything on its own.

The complete demo is on GitHub at kaidn-io/kaidn-go-demo. Clone it, add your keys, and it runs.

If your stack is not Go, the same integration in Python, PHP, Node and Next.js is a few minutes each. The API is identical; only the client changes.

Frequently asked questions

What is device fingerprinting?

Device fingerprinting builds an identifier for a browser from properties it exposes anyway: rendering behaviour, fonts, audio processing, hardware hints. Unlike a cookie, the visitor does not have to keep anything for it to work, so clearing storage or opening a private window does not reset it. It is a probabilistic identifier, not a certain one, which matters more than most vendors admit.

Does the Go client have dependencies?

None. The whole API is JSON over HTTP, so a dependency would buy nothing and cost every user a supply chain to audit. `go get github.com/kaidn-io/kaidn-go` pulls exactly one module and nothing else. If you would rather not add even that, the API is a plain POST and the standard library is enough.

Why not just block duplicate email addresses?

Because that is the check every abuser already expects. Gmail ignores dots and everything after a plus sign, so one inbox produces unlimited addresses that all deliver to the same person. Canonicalising the email is worth doing and Kaidn does it, but on its own it stops only the laziest attempt.

What happens if the fraud API is down?

Your signup must still work. The code here uses a three-second timeout and allows the signup when the call fails, logging it for review. An outage at a fraud vendor should never become an outage in your product, and any integration that blocks users when the vendor is unreachable has traded one problem for a worse one.

Is a device fingerprint enough to block someone?

No, and treating it that way causes false positives concentrated on particular platforms. A raw fingerprint covers 2.30 real people on iOS Safari in our own measurements. That is why the demo weights a device match at 15 rather than letting it block on its own, and why the network is used to corroborate it.

godevice fingerprintingtrial abusetutorial

Score your own traffic

10,000 events a month on the free tier, no card. One POST to /v1/score and you get a verdict with the evidence behind it.

Read next