Design an account takeover detection system
Design the system that stops attackers logging in as your users: credential stuffing and password spraying at login, impossible travel and session anomalies after login, a risk engine that chooses allow, step-up or block, and labels that come from the users who were actually taken over.
01Understand the problem and size it
- Separate the attack shapes: credential stuffing, password spraying, phishing with session theft (AiTM), SIM swap and recovery abuse
- Detect at login (population-level and per-account signals) and after login (session anomaly scoring)
- Build a risk engine that returns allow, step-up, block or notify within a login latency budget
- Get labels from user reports, resets and support cases, and handle their delay and bias
What they probe: why per-IP limits fail against stuffing, how impossible travel produces false positives, how you catch a stolen session cookie that already passed MFA, how the risk engine plugs into step-up, where labels come from. Where candidates slip: only looking at the login event, blocking on impossible travel alone, no story for AiTM phishing, a model with no ground truth.
Account takeover (ATO) is an attacker gaining control of a real user's account. Credential stuffing replays username and password pairs leaked from other sites; success rates are typically 0.1 to 2 % because people reuse passwords. Password spraying is the inverse: a few common passwords tried against many accounts, slowly, to stay under lockout. Adversary-in-the-middle (AiTM) phishing kits proxy the real login page and steal the session cookie after MFA succeeds, so the attacker never needs the password again.
Functional requirements
- Score every login attempt and return a decision to the identity service: allow, step-up, block, or allow and notify.
- Score sensitive in-session events (change email, add payout account, export data) and session anomalies (cookie used from a new device or network).
- Detect population-level attacks (stuffing, spraying) and turn on defences automatically.
- Collect labels from users and analysts and expose cases to the trust and safety team.
Non-functional requirements
- 50 M users, 5 M logins a day; attacks can add 10k attempts/s.
- Login risk decision < 50 ms p99 (the user is waiting).
- If the risk engine is down, logins still work with a safe default.
- Features and decisions are explainable to analysts and to users who appeal.
Back of the envelope
normal logins 5,000,000 / day ≈ 58 / s average, ~300 / s peak (x5)
attack traffic up to 10,000 attempts / s -> design for 10,300 / s at the edge of the risk API
session events 50 per active session x 10 M sessions / day = 500 M / day ≈ 5,800 / s
user profiles 50 M x 2 kB (known devices, countries, ASNs, typical hours) = 100 GB
-> key-value store, 3 replicas = 300 GB, a few memory-optimised nodes
risk decisions log (5 M + 500 M sampled at 10 %) x 1 kB ≈ 55 GB / day02High-level design
- ACTOR
- SERVICE
- DATA
- CONTROL
- Live flow
- Lookup / async
Login-time signals
| Signal | Computed how | Catches |
|---|---|---|
| Credential in a breach corpus | k-anonymity range query: send the first 5 hex chars of SHA-1(password), compare suffixes locally | Stuffing with known leaked pairs |
| Unknown username rate | Share of attempts for usernames that do not exist, per minute, global and per fingerprint | Stuffing lists built for other sites |
| Failure ratio by fingerprint | Failed / total per JA4 + ASN per 5 min | Distributed stuffing over residential proxies |
| Distinct accounts per device cookie or IP | HyperLogLog per key per hour | One client trying many accounts |
| Same password, many accounts | Keyed hash of the attempted password (never stored raw) counted across accounts per hour | Password spraying |
| New device, new country, new ASN | Compare with the user's profile | Targeted takeover |
| Impossible travel | Distance between this and the last successful login divided by elapsed time | Shared credentials, session theft |
Why per-IP limits fail
stuffing run 1,000,000 leaked pairs, 0.5 % success -> 5,000 accounts taken
rate 2,000 attempts / s
proxy pool 50,000 residential IPs
per IP 2,000 / 50,000 = 0.04 / s = 2.4 attempts / min
per-IP limit 10 / min -> never fires
whole run 1,000,000 / 2,000 = 500 s ≈ 8 minutes
what does fire: global failure ratio jumps from ~8 % to ~95 %,
unknown-username share jumps, one JA4 + ASN pair carries 70 % of failuresAfter login: session anomaly scoring
AiTM kits steal the session cookie after MFA, so login-time checks all pass. The defence is to watch the session: the cookie was issued to one device fingerprint, network and user agent; if it is presented from a different JA4, a hosting ASN in another country, or a different User-Agent, that is a strong signal. Score sensitive events (change email, add a forwarding rule, add a payout account) with the session's history, not just the current request. Long term, bind sessions to a device key (Device Bound Session Credentials, or token binding through mTLS) so a copied cookie is useless.
03Deep dives: travel, the risk engine, step-up
Impossible travel, done carefully
Compute great-circle (haversine) distance between the geolocated IPs of two successful authentications and divide by elapsed time. Above roughly 1,000 km/h (faster than an airliner) the pair is physically impossible for one person. London to New York is about 5,570 km; two logins two hours apart imply 2,785 km/h.
const EARTH_KM = 6371;
interface Geo { lat: number; lon: number; accuracyKm: number; atMs: number; asnType: string }
function haversineKm(a: Geo, b: Geo): number {
const rad = (d: number) => (d * Math.PI) / 180;
const dLat = rad(b.lat - a.lat);
const dLon = rad(b.lon - a.lon);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_KM * Math.asin(Math.min(1, Math.sqrt(h)));
}
const valid = (g: Geo) =>
Number.isFinite(g.lat) && Math.abs(g.lat) <= 90 && Number.isFinite(g.lon) && Math.abs(g.lon) <= 180 &&
Number.isFinite(g.atMs) && g.accuracyKm >= 0;
/** Returns a 0..1 contribution to risk, never a verdict on its own. */
export function travelRisk(prev: Geo, cur: Geo): number {
if (!valid(prev) || !valid(cur) || cur.atMs <= prev.atMs) return 0;
if (prev.asnType === "vpn" || cur.asnType === "vpn" || cur.asnType === "mobile") return 0.1; // geo unreliable
const km = Math.max(0, haversineKm(prev, cur) - prev.accuracyKm - cur.accuracyKm); // subtract geo error
const hours = Math.max((cur.atMs - prev.atMs) / 3_600_000, 1 / 60); // floor at 1 minute
const kmh = km / hours;
if (kmh < 1000) return 0;
return Math.min(1, 0.4 + kmh / 20000);
}Corporate VPNs, mobile carrier gateways that egress hundreds of kilometres away, IP geolocation databases that are wrong at city level, and travellers on in-flight Wi-Fi all produce impossible pairs. Use it as one weighted feature that pushes towards step-up, never a lone block, and subtract each location's accuracy radius before dividing.
The risk engine
- Inputs: request context (IP, ASN, JA4, bot score from the edge), user profile, live counters, the action being attempted.
- Scoring: hard rules first (credential in breach corpus and no MFA enrolled, account flagged by analyst), then a gradient-boosted model over around 100 features, then a policy table mapping score band and action sensitivity to a decision.
- Latency: profile read and counter reads in one batched call (2 to 5 ms), model under 2 ms, no synchronous call to anything slower. Breach-corpus lookups are a local Bloom filter or a cached range table.
- Explainability: return the top contributing features as reason codes (
new_device,travel_kmh_2785,stuffing_wave_active) so analysts and the user notification can say what happened.
| Score / action | Login | Change email or MFA | Add payout account |
|---|---|---|---|
| Low | Allow | Fresh MFA within 15 min | Passkey within 5 min |
| Medium | Step-up | Passkey within 5 min plus email to old address | Passkey plus 24 h hold |
| High | Block, notify user | Block, lock sensitive changes | Block, open case |
Step-up integration
The risk engine does not run MFA; it returns { decision: "step_up", acr: "phr", maxAgeSec: 300, reasons } and the IdP asks the MFA service. Prefer phishing-resistant factors for high-risk step-up, because an attacker mid-AiTM can relay a TOTP code or a push approval. After a successful step-up from a new device, add that device to the profile only after a delay (for example 24 h without a user report) so one takeover does not teach the model the attacker is normal.
Population defences that switch on automatically
- When the global failure ratio or unknown-username share crosses a threshold, enter wave mode: raise bot ladder thresholds on the login route, require step-up for any login from a new device, and slow responses for failed logins by a few hundred milliseconds.
- Return the same response and timing for unknown user and wrong password, so the attacker cannot validate usernames.
- Force a password reset for accounts where a successful login used a credential found in a breach corpus.
04Labels, failure modes and trade-offs
Where labels come from
| Source | Delay | Quality |
|---|---|---|
| "This wasn't me" on the new-login email or push | Minutes to days | High precision, low recall (many users ignore notifications) |
| Password reset shortly after a risky login | Hours | Medium; resets happen for other reasons |
| Support tickets tagged takeover | Days | High, but biased to engaged users |
| Downstream fraud (chargebacks, stolen payouts) | Weeks | High for money-moving takeovers only |
| Analyst review of sampled sessions | Days | High, expensive, use for evaluation sets |
Most takeovers are never reported, so "no label" does not mean "legitimate". Train with that in mind (treat unlabelled as weakly negative), evaluate on analyst-reviewed samples, and track recall on reported cases over time rather than trusting accuracy.
Failure modes
| Failure | Behaviour | Why |
|---|---|---|
| Risk engine timeout (> 50 ms) | IdP applies a default: allow known device, step-up new device | Neither fail open nor lock everyone out |
| Profile store region down | Score without profile, which pushes new-device logic to step-up | Missing history should raise caution, not lower it |
| Counter pipeline lagging | Wave mode decisions use last good values; alert on lag > 2 min | Stuffing runs last minutes |
| Mass false positives after a model push | Shadow mode first, canary by user hash, kill switch to rules only | Locking out users is an incident |
Trade-offs to state
| Choice | Gain | Cost |
|---|---|---|
| Synchronous scoring at login | Stop the takeover before the session exists | Latency budget limits features |
| Session anomaly scoring | Catches AiTM cookie theft | Event volume 100x logins; mostly async with alerts |
| Step-up over block | Fewer lost users, attacker still stopped by phishing-resistant factor | Weak factors (SMS, TOTP) can be relayed |
| Delayed profile learning | Attacker's device does not become normal | Real users see extra step-ups for a day |
05Final design, follow-ups and self-test
"Takeover has two halves. At login, credential stuffing comes from tens of thousands of residential IPs at a couple of attempts a minute each, so I watch population signals, failure ratio and unknown usernames by fingerprint, not per-IP limits, and I check passwords against breach corpora with a k-anonymity range lookup. After login, AiTM phishing steals cookies that already passed MFA, so I score sessions for fingerprint and network drift and gate sensitive actions on fresh, phishing-resistant step-up. The risk engine combines rules, a model and a policy table in under 50 ms, falls back to step-up on new devices if it's down, and learns from user reports with the delay and bias that comes with them."
Q1How do you stop the risk engine itself being used to enumerate valid usernames?+
Identical responses and timing for unknown users and wrong passwords, and score before revealing anything account-specific. Step-up prompts should only appear after a correct password.
Q2A user travels a lot and keeps getting step-up prompts. How do you reduce friction?+
Phishing-resistant step-up once per new device, then trust the device; weigh travel lower for users with a history of travel; use device binding rather than location as the main anchor.
Q3How do you evaluate a new model before it touches users?+
Shadow mode: score live traffic, log decisions, compare against the current model on analyst-reviewed samples and reported takeovers, then canary on a small user hash range.
Q4What do you do when a stuffing wave succeeds against 2,000 accounts?+
Invalidate their sessions, force password reset, block sensitive changes until step-up, notify users, and feed the successful credential pairs into the breach check.
A credential stuffing run uses 50,000 residential IPs at 2,000 attempts per second. Which signal is most likely to detect it quickly?