Skip to content

Device Fingerprint

A second-line defense against mass account creation. We cap the number of accounts that can be registered from a single device to 3 per rolling 30-day window, independent of IP. This catches abuse that rotates through residential proxies but reuses the same browser/app install.

Source:

Where the fingerprint comes from

The frontend computes a stable fingerprint per platform and sends it on registration:

Platform Source
Android (native) Android Settings.Secure.ANDROID_ID (or equivalent) surfaced via the native bridge
iOS (native) identifierForVendor (or a stored UUID minted on first launch)
Web Hash of canvas + WebGL + screen + user-agent properties via FingerprintJS (or equivalent)

The fingerprint is sent in the device_fingerprint field of the registration request DTO. It's also stored on the User row (models.User.DeviceFingerprint) for traceability.

How the limit is enforced

// backend/internal/services/auth_service.go
func (s *AuthService) checkDeviceFingerprint(fingerprint string) error {
    if fingerprint == "" || fingerprint == "unknown" {
        return nil // skip — don't globally block on empty/unknown
    }

    deviceKey := fmt.Sprintf("register_device:%s", fingerprint)
    val, err := s.redisService.Get(deviceKey)
    if err == nil && val != "" {
        count := 0
        _, _ = fmt.Sscanf(val, "%d", &count)
        if count >= 3 {
            return errors.New("maximum number of accounts reached for this device (limit: 3)")
        }
    }
    return nil
}

func (s *AuthService) incrementDeviceRegistration(fingerprint string) {
    // ...
    _ = s.redisService.Set(deviceKey, fmt.Sprintf("%d", count), 30*24*time.Hour)
}

The Redis key:

register_device:<fingerprint>  -> "<int>"   TTL: 30 days

Each successful registration increments the counter and refreshes the 30-day TTL. The fourth attempt within the window is rejected with:

{ "error": "maximum number of accounts reached for this device (limit: 3)" }

Failure modes and edge cases

  • Empty or "unknown" fingerprint — the check is skipped to avoid global blocking when a client fails to compute one. This is a trade-off; an attacker who omits the field gets through this gate but is still rate-limited by IP (5/hour) and email-OTP'd at signup.
  • Redis unreachableGet returns an error and the check passes (fail-open), consistent with the rest of the limiting stack.
  • Mobile reinstalls — uninstalling/reinstalling the app may regenerate the iOS identifierForVendor. We accept this leak as the cost of not requiring more invasive fingerprinting.
  • Web sharing a device — multiple genuine users on one shared machine will exhaust the limit. Support can clear the counter manually:
redis-cli DEL register_device:<fingerprint>

Why 3 and 30 days?

The numbers are tuned empirically to allow:

  • Most household sharing (couple + a kid)
  • Test accounts during local development
  • One forgotten-credentials retry

…while still defeating bulk account creation. Tighten or loosen by editing the constants in auth_service.go — there's no config knob today.

This is the last of several gates against fake-account spam:

  1. IP rate limit — 5 registrations/hour per IP (see Security → Rate Limiting)
  2. Email OTP at signup — must own the address
  3. Device fingerprint limit — 3 per device per 30 days (this page)
  4. Phone number — required for events (extra friction)

Each is independent; an attacker has to defeat all four to scale.