Two signals
Most production fraud-detection stacks have hundreds of signals. Two of them produce most of the actual catches:
-
Impossible travel. Login from Pune at 10:00 UTC, another from London at 10:15 UTC. 7,400 km in 15 minutes is impossible by aircraft, let alone in-flight WiFi. The session is compromised or shared. (A passkey-based authentication scheme eliminates credential theft but doesn't prevent session hijacking, so you still need this check.)
-
Credential-stuffing density. A single IP making login attempts against many accounts in a short window. Either a stuffer trying leaked creds or a poorly-configured corporate proxy. Either way, throttle.
Impossible travel in Go
type Login struct {
UserID string
Latitude float64
Longitude float64
At time.Time
}
func haversineKm(a, b Login) float64 {
const R = 6371.0 // earth radius in km
lat1, lat2 := a.Latitude*math.Pi/180, b.Latitude*math.Pi/180
dLat := (b.Latitude - a.Latitude) * math.Pi / 180
dLon := (b.Longitude - a.Longitude) * math.Pi / 180
h := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1)*math.Cos(lat2)*math.Sin(dLon/2)*math.Sin(dLon/2)
return 2 * R * math.Asin(math.Sqrt(h))
}
func impossibleTravel(prev, curr Login, maxKmH float64) bool {
distKm := haversineKm(prev, curr)
hours := curr.At.Sub(prev.At).Hours()
if hours <= 0 { return false }
return distKm / hours > maxKmH
}
maxKmH set to 1000 (faster than any commercial flight) catches the obvious cases. This is part of the broader P2P lending fraud detection pipeline. Set to 200 for very paranoid configurations (catches train + car combos that are physically possible but unusual for the user’s pattern).
Credential-stuffing density
type StufferScore struct {
AttemptsLastMinute int
UniqueUsersLast5Min int
FailureRateLast5Min float64
}
func suspicious(score StufferScore) bool {
return score.AttemptsLastMinute > 30 ||
score.UniqueUsersLast5Min > 10 ||
score.FailureRateLast5Min > 0.7
}
Three thresholds. Tuned per workload. Tracked per IP in Redis with sliding-window counters.
Genie’s implementation
agents/cyber_guardian runs both checks plus a couple more (unknown device, fingerprint churn) on every login event. The output is a score 0-1; thresholds (configured per-tenant in the FREE-AI policy YAML) trigger:
- Score 0.0-0.3: log + continue
- Score 0.3-0.6: log + soft step-up (require recent password re-enter)
- Score 0.6-1.0: log + hard block + incident + alert to security team
The thresholds map to FREE-AI Rec 19 (Cybersecurity). Each block writes an Annexure VI incident with FailureMode = FailureUnintendedAction so the regulator can see the cumulative rate.
What this misses
Two attack classes need other signals:
- Slow-burn stuffing (1 attempt per IP, but 10K IPs over hours). Density-by-IP fails; need ASN-level density.
- Targeted account takeover with the right device fingerprint and the right location. Impossible-travel doesn’t trigger; need behavioural signals (typing cadence, mouse patterns).
For production, these two are the foundation. The advanced signals layer on top. Starting with these catches most volume; the long-tail attackers are a quarter-on-quarter investment.