CYART // SCHEMA · SSO-IDP7 NODES · 7 LINKSOPEN APP1AUTHN REQUEST2LOOKUPSIGN TOKENID TOKEN / SAML3ACTOR · UserACTOR01UserSERVICE · App (SP / RP) — redirects, verifiesSERVICE02App (SP / RP)redirects, verifiesSERVICE · IdP core — sessions, MFA, policiesSERVICE03IdP coresessions, MFA, policiesDATA · Directory — users, groups, SCIMDATA04Directoryusers, groups, SCIMDATA · Signing keys — HSM, kid rotationDATA05Signing keysHSM, kid rotationCONTROL · Risk signals — new device, impossible travelCONTROL06Risk signalsnew device, impossible travelDATA · Audit — every login eventDATA07Auditevery login event

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.

● HardFREEDesign problem·20 min read·Intermediate → Staff
account takeoverATO detectioncredential stuffingimpossible travelrisk enginesession hijacking

01Understand the problem and size it

✓What you will learn
  • 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
◎Interview radar

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

  1. Score every login attempt and return a decision to the identity service: allow, step-up, block, or allow and notify.
  2. Score sensitive in-session events (change email, add payout account, export data) and session anomalies (cookie used from a new device or network).
  3. Detect population-level attacks (stuffing, spraying) and turn on defences automatically.
  4. Collect labels from users and analysts and expose cases to the trust and safety team.

Non-functional requirements

  1. 50 M users, 5 M logins a day; attacks can add 10k attempts/s.
  2. Login risk decision < 50 ms p99 (the user is waiting).
  3. If the risk engine is down, logins still work with a safe default.
  4. Features and decisions are explainable to analysts and to users who appeal.

Back of the envelope

Sizingtxt
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 / day
▭On the whiteboard · Scope3 points
01
FR
score login + sensitive events + sessions | detect population attacks | labels + cases
02
NFR
50 M users | 10k attempts/s attack | < 50 ms p99 | safe default when down | explainable
03
out
the IdP, the MFA service (we call it), the bot ladder (we consume its score)

02High-level design

FigWhere the risk engine sits
CYART // SCHEMA · SSO-IDP7 NODES · 7 LINKSOPEN APP1AUTHN REQUEST2LOOKUPSIGN TOKENID TOKEN / SAML3ACTOR · UserACTOR01UserSERVICE · App (SP / RP) — redirects, verifiesSERVICE02App (SP / RP)redirects, verifiesSERVICE · IdP core — sessions, MFA, policiesSERVICE03IdP coresessions, MFA, policiesDATA · Directory — users, groups, SCIMDATA04Directoryusers, groups, SCIMDATA · Signing keys — HSM, kid rotationDATA05Signing keysHSM, kid rotationCONTROL · Risk signals — new device, impossible travelCONTROL06Risk signalsnew device, impossible travelDATA · Audit — every login eventDATA07Auditevery login event
  • ACTOR
  • SERVICE
  • DATA
  • CONTROL
  • Live flow
  • Lookup / async
//The IdP calls the risk engine synchronously on each login and sensitive action, passing the request context and the bot score from the edge. The risk engine reads user profiles and live counters, returns a decision with reasons, and the IdP calls the MFA service for step-up when asked. Every event also flows to a stream that updates counters, profiles and the case queue.

Login-time signals

SignalComputed howCatches
Credential in a breach corpusk-anonymity range query: send the first 5 hex chars of SHA-1(password), compare suffixes locallyStuffing with known leaked pairs
Unknown username rateShare of attempts for usernames that do not exist, per minute, global and per fingerprintStuffing lists built for other sites
Failure ratio by fingerprintFailed / total per JA4 + ASN per 5 minDistributed stuffing over residential proxies
Distinct accounts per device cookie or IPHyperLogLog per key per hourOne client trying many accounts
Same password, many accountsKeyed hash of the attempted password (never stored raw) counted across accounts per hourPassword spraying
New device, new country, new ASNCompare with the user's profileTargeted takeover
Impossible travelDistance between this and the last successful login divided by elapsed timeShared credentials, session theft

Why per-IP limits fail

Credential stuffing arithmetictxt
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 failures

After 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.

▭On the whiteboard · Core3 points
01
login
breach check + population counters + profile diff + travel → score
02
session
fingerprint drift, sensitive events, new forwarding rules
03
decide
score x action sensitivity → allow / step-up / block / notify

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.

Impossible travel as a weighted featurets
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);
}
!Impossible travel alone is a false positive machine

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 / actionLoginChange email or MFAAdd payout account
LowAllowFresh MFA within 15 minPasskey within 5 min
MediumStep-upPasskey within 5 min plus email to old addressPasskey plus 24 h hold
HighBlock, notify userBlock, lock sensitive changesBlock, 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

SourceDelayQuality
"This wasn't me" on the new-login email or pushMinutes to daysHigh precision, low recall (many users ignore notifications)
Password reset shortly after a risky loginHoursMedium; resets happen for other reasons
Support tickets tagged takeoverDaysHigh, but biased to engaged users
Downstream fraud (chargebacks, stolen payouts)WeeksHigh for money-moving takeovers only
Analyst review of sampled sessionsDaysHigh, expensive, use for evaluation sets
iPositive and unlabelled

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

FailureBehaviourWhy
Risk engine timeout (> 50 ms)IdP applies a default: allow known device, step-up new deviceNeither fail open nor lock everyone out
Profile store region downScore without profile, which pushes new-device logic to step-upMissing history should raise caution, not lower it
Counter pipeline laggingWave mode decisions use last good values; alert on lag > 2 minStuffing runs last minutes
Mass false positives after a model pushShadow mode first, canary by user hash, kill switch to rules onlyLocking out users is an incident

Trade-offs to state

ChoiceGainCost
Synchronous scoring at loginStop the takeover before the session existsLatency budget limits features
Session anomaly scoringCatches AiTM cookie theftEvent volume 100x logins; mostly async with alerts
Step-up over blockFewer lost users, attacker still stopped by phishing-resistant factorWeak factors (SMS, TOTP) can be relayed
Delayed profile learningAttacker's device does not become normalReal users see extra step-ups for a day

05Final design, follow-ups and self-test

▭On the whiteboard · Final design3 points
01
login
breach check, population counters by fingerprint, profile diff, travel as a weighted feature
02
session
fingerprint drift and sensitive-event scoring, device-bound sessions as the end state
03
decide
rules + model + policy table, step-up via IdP with phishing-resistant factors, labels from reports and resets
“How to say it in the room

"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.

Test yourself

A credential stuffing run uses 50,000 residential IPs at 2,000 attempts per second. Which signal is most likely to detect it quickly?

Finished this one?
Tick it and it shows as done on the roadmap and the course.