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:
backend/internal/services/auth_service.go—checkDeviceFingerprintandincrementDeviceRegistrationbackend/internal/handlers/auth_handler.go— extractsdevice_fingerprintfrom the request
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 unreachable —
Getreturns 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.
Related defenses¶
This is the last of several gates against fake-account spam:
- IP rate limit — 5 registrations/hour per IP (see Security → Rate Limiting)
- Email OTP at signup — must own the address
- Device fingerprint limit — 3 per device per 30 days (this page)
- Phone number — required for events (extra friction)
Each is independent; an attacker has to defeat all four to scale.