0

Design an account takeover detection system

by · 1 day ago· ⑂ 0 remixes

Question: Design an account takeover detection system

Companies: Stripe

Difficulty: Medium

Problem statement

Spot logins and account changes made by someone other than the owner, scoring each event in real time and choosing to allow, challenge or block.

Problem framing

Business goal. Stop attackers who get into a merchant's dashboard account from moving money out (changing the payout bank account, issuing refunds to themselves, creating API keys) while keeping friction for real owners near zero. A blocked owner cannot run their business, so false positives are expensive too.

ML task. For every security-relevant event — login attempt, password reset, email/phone change, 2FA change, payout account change, API key creation, team member invite — predict P(event is performed by someone other than the account owner) at the moment it happens, and pick one action: allow, challenge (step-up 2FA, email confirmation, hold the change for 24h) or block (deny and lock the session, notify the owner).

Constraints.

  • ~50M login attempts/day ≈ 580/s average, ~1.7k/s daily peak; credential-stuffing bursts can push 20k+/s from botnets, so the path must not fall over under attack.
  • ~5M sensitive account changes/day, much lower QPS but much higher stakes.
  • Inline decision, p99 < 100 ms for logins, < 200 ms for account changes.
  • Positive rate is tiny: about 1 in 10k successful logins is a confirmed takeover.

ML objective vs business metric. The model is trained on calibrated log-loss for "not the owner". The business metric is dollars lost to ATO per 1k active accounts plus good-user friction (challenge rate on owners, challenge abandonment, support tickets about lockouts). The policy layer turns the probability into an action by expected cost, so the model's job is a well-calibrated, well-ranked probability, not a hard label.

Data and labels

Sources.

  • Auth logs: every login attempt with outcome (bad password, good password, 2FA pass/fail), IP, user agent, device cookie, client fingerprint.
  • Account-change audit log: field changed, old/new value (e.g. new bank's country), session that did it.
  • Session telemetry: page views, time between actions, typing/paste signals from the dashboard client.
  • Money movement: payouts, refunds, balance at time of event.
  • Outcome signals: owner reports ("this wasn't me"), support-confirmed ATO cases, payouts reversed for fraud, password-reset-by-owner shortly after a foreign login, breached-credential feeds.

Label definition. An event is positive if it happened in a session later confirmed as compromised (support case, owner dispute, or fraud-ops review). Propagate the label to every event in that session and to the login that started it. Negatives are events in sessions with no ATO signal after a 30-day maturity window.

Label delay. Owners often notice only when a payout goes missing: median 2–4 days, long tail to weeks. Train only on events older than 30 days; use faster proxies (owner clicked "not me" in the new-login email, failed challenge followed by owner reset) for monitoring, not as the main training label.

Weak labels and their traps.

  • Failed challenge is noisy: owners fail 2FA too (lost phone). Use as a feature of history, not a positive label.
  • Passed challenge is not a clean negative: SIM-swap and real-time phishing kits pass SMS OTP.
  • Blocked events have no outcome — we never learn whether they were the owner. This is a selective-labels problem; see exploration below.

Class imbalance. Keep all positives; keep negatives at rate r ≈ 1/100 to 1/200, then correct the score back to the true base rate (p = r·p_s / (r·p_s + 1 − p_s)) so calibration survives.

Leakage traps.

  • Features must be computed with an as-of timestamp equal to the event time; "number of password resets in the last 24h" computed at training time from the full log will include the owner's reset after the attack.
  • Support-case fields, fraud-ops review flags, and payout reversal status are post-event and must never be features.
  • The current production score and the action taken are outputs of the policy; feeding them back creates a loop. Log them, but don't train on them.
  • Split by time (train on months 1–5, validate on month 6, test on month 7), and keep all events of one account in one fold within a period so per-account history doesn't leak across folds.

Exploration for unbiased labels. For 0.5–1% of events scored in the "block" band where the account's value at risk is low, downgrade to a strong challenge instead. Their outcomes give labeled data in the region the policy otherwise hides, and they let us estimate counterfactual policy performance with inverse propensity weighting.

Features

Account familiarity (the strongest group). Compare the event to the owner's own history:

  • Device cookie seen on this account before? Days since first seen, number of successful logins from it.
  • IP / /24 / ASN seen before; country and city seen before; distance and implied speed from last successful login (impossible travel > 900 km/h).
  • User agent family, OS, browser language, timezone offset vs historical mode.
  • Hour-of-week vs the account's login-time histogram.

Network reputation. Datacenter/hosting ASN, known VPN/proxy/Tor exit, residential-proxy score, IP age in our logs, fraction of past events from this IP that were confirmed ATO.

Velocity (streaming counters). Per IP, /24, ASN, device and account over 1 min / 1 h / 24 h: failed logins, distinct accounts tried, distinct passwords tried, success ratio. Credential stuffing shows as one IP or device trying hundreds of accounts at a 0.1–2% success rate.

Credential signals. Password appears in a breach corpus (checked by hash prefix at set time), password age, recent password reset, 2FA type (none / SMS / TOTP / passkey).

Session sequence (for account changes). Time since login, whether the login itself was risky, the ordered actions so far (new device login → email change → 2FA removal → payout account change within 15 min is the classic pattern), whether the new bank account or email domain has been seen on other compromised accounts.

Value at risk. Available balance, typical daily payout volume, account age. Used both as features and by the policy for cost.

Online vs offline. Velocity counters come from a stream processor (Flink) writing to Redis with seconds of freshness. Account familiarity profiles (device/IP/geo sets, time histograms) are updated on every successful login and stored per account in the online store. Reputation tables (ASN, IP history) are batch-built hourly. A feature store with point-in-time joins keeps training and serving definitions identical; this matters most here because a familiarity feature that's off by one event (including the current login in "seen before") makes every device look familiar offline.

Model

Baseline: rules. Block on known-bad IPs and stuffing velocity; challenge when new device AND new country, or on any payout change within 1 h of a new-device login. This catches the blatant cases, is explainable, and is the fallback when the model is down. Its weakness: fixed thresholds, high friction for travelers, easily learned by attackers.

Production: two layers.

  1. Pre-auth filter (cheap, before password check). Rate limits and an IP/device-level model on velocity and reputation. Its job is to absorb stuffing bursts (CAPTCHA or drop) so the main model and the auth database see normal load.
  2. Event risk model (GBDT, e.g. LightGBM, ~300 features). One model with event_type as a feature, or separate heads for login vs sensitive change if their feature sets diverge. Weighted binary cross-entropy with sample-weight correction, monotonic constraints on a few features (more failed attempts never lowers risk) to keep behavior sane on sparse regions. GBDT fits mixed tabular features, trains in minutes, and gives SHAP explanations to fraud ops.

Optional additions once the GBDT plateaus.

  • A sequence model (small GRU/transformer) over the session's last ~50 events, its output fed as a feature to the GBDT.
  • Graph features: shared device/IP/bank account across accounts, connected-component size to known-ATO nodes, refreshed hourly.

Calibration. Isotonic regression on the latest validation month, per event type. Required because the policy multiplies the probability by dollars.

Decision policy (expected cost). Let p = calibrated probability, L = loss if ATO succeeds (≈ value at risk, say $2,000), e = probability a challenge stops an attacker (0.9 for TOTP, lower for SMS), c_f = cost of challenging an owner ($1 of friction and abandonment), c_b = cost of blocking an owner ($50 of support and churn).

  • Allow: p·L
  • Challenge: (1−p)·c_f + p·(1−e)·L
  • Block: (1−p)·c_b

Challenge beats allow when p > c_f / (e·L + c_f) ≈ 1 / 1,801 ≈ 0.00056. Block beats challenge when (1−p)(c_b − c_f) < p(1−e)L, i.e. p > 49 / 249 ≈ 0.20. Because L is per account, a $200k-balance account gets challenged at a much lower score than a new one with $0. Hard overrides (block on confirmed-bad device, always hold payout-account changes from a device < 1 h old for 24 h) sit on top.

Evaluation

Offline.

  • PR-AUC and recall at a fixed owner-challenge rate (e.g. recall of ATO logins when ≤ 1% of good logins are challenged). ROC-AUC looks great at 1:10k imbalance and hides differences.
  • Dollar-weighted recall: share of value-at-risk in ATO sessions caught. A model that catches small accounts and misses large ones is worse than its event recall says.
  • Expected policy cost on the test month using the cost formula above, compared to the current policy; use IPS weights from the exploration traffic for events the current policy blocked.
  • Calibration: reliability curves and expected calibration error per event type and per value-at-risk bucket.
  • Slices: new accounts, travelers (many countries in history), mobile vs desktop, SMS-only 2FA, each region. Look for groups whose challenge rate is 3x+ the average.

Online.

  • A/B by account (not by event, so one account sees one policy), 2–4 weeks, because ATO is rare and labels mature slowly. Primary: confirmed ATO loss per 1k accounts (read out after the 30-day window) and owner challenge rate. Faster proxies: "not me" clicks on new-login emails, challenge failure rate on sessions later confirmed bad.
  • Guardrails: login success rate for owners, challenge abandonment, lockout support tickets, p99 latency, payout-change completion time.
  • Attackers adapt to whichever arm is weaker, so keep a small permanent holdout on the old policy to detect drift in both arms.

Serving and monitoring

The online scoring path:

{"type":"excalidraw","elements":[
 {"type":"rectangle","id":"c","x":0,"y":140,"width":180,"height":70,"label":{"text":"Dashboard or\nAPI client"}},
 {"type":"rectangle","id":"e","x":260,"y":140,"width":180,"height":70,"label":{"text":"Edge rate\nlimiter"}},
 {"type":"rectangle","id":"auth","x":520,"y":140,"width":180,"height":70,"label":{"text":"Auth service"}},
 {"type":"rectangle","id":"risk","x":860,"y":140,"width":180,"height":70,"label":{"text":"Risk API"}},
 {"type":"ellipse","id":"fs","x":1180,"y":130,"width":220,"height":90,"label":{"text":"Online feature\nstore: Redis"}},
 {"type":"rectangle","id":"gbdt","x":860,"y":330,"width":180,"height":70,"label":{"text":"GBDT scorer"}},
 {"type":"rectangle","id":"pol","x":510,"y":330,"width":200,"height":70,"label":{"text":"Policy: allow,\nchallenge, block"}},
 {"type":"rectangle","id":"kafka","x":860,"y":520,"width":180,"height":70,"label":{"text":"Event log\nin Kafka"}},
 {"type":"rectangle","id":"flink","x":1180,"y":520,"width":200,"height":70,"label":{"text":"Stream processor:\nFlink"}},
 {"type":"arrow","id":"c-e","x":180,"y":175,"width":80,"height":0,"start":{"id":"c"},"end":{"id":"e"}},
 {"type":"arrow","id":"e-auth","x":440,"y":175,"width":80,"height":0,"start":{"id":"e"},"end":{"id":"auth"}},
 {"type":"arrow","id":"auth-risk","x":700,"y":175,"width":160,"height":0,"start":{"id":"auth"},"end":{"id":"risk"},"label":{"text":"event"}},
 {"type":"arrow","id":"risk-fs","x":1040,"y":175,"width":140,"height":0,"start":{"id":"risk"},"end":{"id":"fs"}},
 {"type":"arrow","id":"risk-gbdt","x":950,"y":210,"width":1,"height":120,"start":{"id":"risk"},"end":{"id":"gbdt"}},
 {"type":"arrow","id":"gbdt-pol","x":860,"y":365,"width":-150,"height":0,"start":{"id":"gbdt"},"end":{"id":"pol"}},
 {"type":"arrow","id":"pol-auth","x":610,"y":330,"width":1,"height":-120,"start":{"id":"pol"},"end":{"id":"auth"}},
 {"type":"arrow","id":"auth-kafka","x":641,"y":210,"width":278,"height":310,"start":{"id":"auth"},"end":{"id":"kafka"}},
 {"type":"arrow","id":"pol-kafka","x":673,"y":400,"width":214,"height":120,"start":{"id":"pol"},"end":{"id":"kafka"}},
 {"type":"arrow","id":"kafka-flink","x":1040,"y":555,"width":140,"height":0,"start":{"id":"kafka"},"end":{"id":"flink"}},
 {"type":"arrow","id":"flink-fs","x":1281,"y":520,"width":8,"height":-300,"start":{"id":"flink"},"end":{"id":"fs"}}
]}

The training loop that feeds the scorer:

{"type":"excalidraw","elements":[
 {"type":"rectangle","id":"kafka","x":0,"y":140,"width":180,"height":70,"label":{"text":"Event log\nin Kafka"}},
 {"type":"ellipse","id":"wh","x":330,"y":130,"width":200,"height":90,"label":{"text":"Warehouse"}},
 {"type":"rectangle","id":"labels","x":320,"y":-80,"width":220,"height":70,"label":{"text":"Labels: support,\ndisputes, reversals"}},
 {"type":"rectangle","id":"train","x":660,"y":140,"width":180,"height":70,"label":{"text":"Point-in-time\ntraining job"}},
 {"type":"rectangle","id":"val","x":960,"y":140,"width":180,"height":70,"label":{"text":"Validation and\ncalibration"}},
 {"type":"rectangle","id":"reg","x":1260,"y":140,"width":180,"height":70,"label":{"text":"Model registry"}},
 {"type":"rectangle","id":"gbdt","x":1260,"y":330,"width":180,"height":70,"label":{"text":"GBDT scorer"}},
 {"type":"arrow","id":"kafka-wh","x":180,"y":175,"width":150,"height":0,"start":{"id":"kafka"},"end":{"id":"wh"}},
 {"type":"arrow","id":"labels-wh","x":430,"y":-10,"width":1,"height":140,"start":{"id":"labels"},"end":{"id":"wh"}},
 {"type":"arrow","id":"wh-train","x":530,"y":175,"width":130,"height":0,"start":{"id":"wh"},"end":{"id":"train"}},
 {"type":"arrow","id":"train-val","x":840,"y":175,"width":120,"height":0,"start":{"id":"train"},"end":{"id":"val"}},
 {"type":"arrow","id":"val-reg","x":1140,"y":175,"width":120,"height":0,"start":{"id":"val"},"end":{"id":"reg"}},
 {"type":"arrow","id":"reg-gbdt","x":1350,"y":210,"width":1,"height":120,"start":{"id":"reg"},"end":{"id":"gbdt"}}
]}

Latency budget (p99, login path, 100 ms). Network to Risk API 5 ms; feature fetch from Redis, parallel multi-get of ~20 keys 25 ms; graph and reputation lookups 15 ms; GBDT inference 5 ms; policy and logging 5 ms; ~45 ms headroom. On timeout: logins fail open to the rules engine (blocking all logins in an outage is worse); payout-account and 2FA changes fail closed to a challenge.

Scale. At 1.7k/s peak with ~5 ms CPU per score, ~10 cores of scoring; run 3x that across zones for attack bursts, with the pre-auth filter absorbing the stuffing volume before it reaches the scorer.

Monitoring.

  • Score distribution and action mix per event type, per 5 minutes. A spike in block rate usually means an attack, not drift; page fraud ops with the top contributing features.
  • Feature null and default rates (a broken device-cookie pipeline makes every login "new device" and challenges everyone).
  • PSI on top features weekly; calibration on matured labels monthly.
  • Training-serving skew: replay a sample of logged requests through the offline feature pipeline daily and diff.

Feedback loops. Challenged attackers who fail never produce a loss, so their sessions look negative unless the label pipeline counts "failed challenge + owner later reset password" as positive. Blocked events are unlabeled except for exploration traffic. Without exploration the model trains only on what it allowed and slowly loses sight of the patterns it already blocks.

Retraining. Weekly retrain on a rolling 6-month window ending 30 days ago (matured labels), auto-promote only if expected policy cost and calibration on the latest month beat the current model. New attack patterns get a rule within hours; the model learns them on the next retrain once labels mature.

Trade-offs

  • Expected-cost policy vs fixed thresholds: adapts to account value and challenge strength, but depends on calibration and on cost estimates (c_f, c_b) that are guesses and must be revisited.
  • Fail open on login: keeps owners working during an outage, but gives attackers a window if they can force timeouts; fail closed on money-moving changes accepts friction to cap loss.
  • 24 h hold on payout-account changes: stops most cash-out, but annoys owners who legitimately switch banks; an email "cancel this change" link limits the cost.
  • GBDT over deep models: fast, explainable to fraud ops, cheap to retrain, but misses long-range session patterns that a sequence model would catch.
  • 30-day label maturity: clean labels, but the model is always a month behind attackers; rules and fast proxies cover the gap.
  • Exploration traffic: gives unbiased labels in the block band, but lets a small, capped number of risky events through as challenges.
  • SMS as a challenge: highest completion rate, but weakest against SIM swap; lower e means the policy blocks more for SMS-only accounts.
  • Device fingerprinting depth: more signal, but privacy constraints and browser changes erode it; keep it to what's needed for familiarity.

Interviewer expectations

Mid-level (L4)

  • Frames it as binary classification with allow/challenge/block thresholds and handles class imbalance.
  • Names device, IP, geo and velocity features and uses a GBDT.
  • Uses PR-AUC, not accuracy, and a time-based split.

Senior (L5)

  • Separates credential stuffing (pre-auth, IP-level) from targeted takeover (account familiarity), and login from sensitive account changes.
  • Defines labels with a maturity window, calls out point-in-time features and post-event leakage.
  • Sets thresholds by expected cost with calibration; picks fail-open vs fail-closed per event type; plans an A/B by account with friction guardrails.

Staff (L6+)

  • Raises selective labels from blocking and fixes them with exploration and IPS-based policy evaluation.
  • Makes value at risk and challenge strength part of the decision, and plans for adversarial adaptation (holdouts, rules for fast response, graph features across accounts).
  • Owns the system around the model: owner notifications, payout holds, fraud-ops tooling with explanations, and the cost of lockouts to the business.

Sign in to comment, vote or remix.