Resources Docs Free Blog Contact
Log in Get started
SIG-915 · MASKBREAK RESEARCH
Fraud playbooks

Account Takeover Prevention: Lookup, Evaluate, Then Compare

Evaluate after the account lookup and before the hash compare: the only point where linked_accounts exists and no bcrypt round has been spent.

In short
  • Evaluate after the account lookup and before the hash compare: that is where linked_accounts exists and no bcrypt round is spent.
  • Pass the claimed accountId on every attempt: the device links to each account it tries, so the ring shows before any password works.
  • A stuffing run behind a residential pool rotates addresses, not machines; count velocity on device.visitor_id and account before IP.
  • Route on device.antidetect and the reason code, not tampering_score: on our bench the score read 0.0029 on every profile.
  • A refusal must return the same failure shape as a wrong password; a distinguishable refusal tells the operator to rotate exits and retry.
On this page
  1. How an account takeover run is put together
  2. Why the usual controls miss it
  3. The tells, and what each one is worth
  4. Where the check goes: lookup, evaluate, then compare
  5. The response ladder
  6. What it costs you
  7. Checklist

A correct password from a machine the account has never seen is the whole of account takeover, and a password check has nothing to say about it. The credential is right. By the time you can prove it was stolen, the session exists and the email on the account has changed.

The defense is a question of ordering. There is one point in a login handler where you know which account is claimed, have not spent a hash compare, and have issued nothing. That is where the evaluation goes. What follows: how the attack is run, what it leaves in a verdict, the code at that point, and a three-tier response that spares the customer with a new laptop.

How an account takeover run is put together

Every stage is something the operator buys. This is one of the attack playbooks; the first stage has its own post — what a stuffing run looks like once your per-IP limit starts working — so it is compressed here.

  1. Assemble a combo list. Email and password pairs from other sites' breaches, sorted by where they were used.
  2. Load it into a checker. OpenBullet 2 (checked 2026-08-30) calls itself "a cross-platform automation suite" for "requests towards a target webapp"; a per-site config encodes your login request and its success condition.
  3. Route through a residential pool. Oxylabs (checked 2026-09-01) sells the product plainly — "real residential IP addresses provided by Internet Service Providers", connected to "real mobile and desktop devices" — so every attempt leaves from a different home address; what still identifies a residential proxy after the address rotates is the pillar on that half.
  4. Solve the challenges. A CAPTCHA goes to a solver; how CAPTCHA farms price a solve covers that market.
  5. Grade the hits. Valid pairs are sorted by what is attached: a stored card, a balance, a payout method, a verified phone.
  6. Cash out or resell. The operator opens the account in an antidetect browser such as Kameleo or GoLogin, changes the email and phone, adds a payout destination, and drains it. None of that is a login event.

It enters your stack at POST /login, at password reset, at the mobile API that skips your web protections, and at any "link an account" flow that accepts a password.

What a run looks like in your logs (worked model)

Every input is assumed, not measured: 5,000 pairs, 1 in 100 valid on your site; one operator, three browser profiles; a residential pool in your users' country; four hours; a per-IP limit of ten failures per fifteen minutes already in place; the evaluation in log-only mode with accountId sent.

TimeThe login log showsThe verdict shows (log-only)
0:00 to 0:30620 attempts from 610 residential IPs in one country; the per-IP counter peaks at 2.Three device.visitor_id values across all 620; proxy_detected on the pool's exits; device.linked_accounts past 100 on each device.
0:30 to 1:00The first six successes, each an ordinary 200 with a session cookie. Nothing alerts.The same three ids, each with multi_account_device in reasons and device.first_seen inside the window.
1:00 to 1:30On two taken accounts: email changed, phone changed. No login involved.Nothing, unless those forms are evaluated too (below).
4:005,000 attempts, about 4,900 IPs, roughly 50 successes. A mild rise in failed logins on the dashboard.Three devices, each linked to more than a thousand accounts.

Note the units. The log counts addresses, of which the operator has an unlimited supply. The verdict counts devices, and the operator has three.

Why the usual controls miss it

Per-IP rate limits. Keep them; they stop the lazy version. Against a pool they count the wrong noun. The OWASP credential stuffing cheat sheet (checked 2026-08-30) says IP blocking "should not be used as the sole or primary defense due to the ease in circumvention".

Account lockout. NIST SP 800-63B (revision dated 2025-08-26, checked 2026-08-30) caps consecutive failures on one account at "no more than 100" before the authenticator is disabled. A stuffing run makes one attempt per account and never nears the counter; its only effect is on the customer who mistypes.

CAPTCHA. A solve is a line item in the checker's config; it raises the cost per attempt and does nothing to the attempts that get through.

Breached-password screening. Worth doing, because it shrinks the list. The Pwned Passwords range search (checked 2026-08-30) takes the first five characters of a SHA-1 hash and is "freely accessible without the need for a subscription and API key". It tests the password, not the session.

MFA. The same OWASP cheat sheet calls it "by far the best defense against the majority of password-related attacks", and it is. Its limit is in NIST's section 3.2.5: authenticators "that involve the manual entry of an authenticator output … SHALL NOT be considered phishing-resistant". A real-time phishing page relays the code. MFA belongs in the ladder as the step-up, and the ladder decides when to ask.

Device fingerprinting, read naively. OWASP's caveat: "all this information is provided by the client, it can potentially be spoofed by an attacker". That is what an antidetect browser is sold to do. What survives is not the fingerprint but two facts about it: whether the browser's story matches the engine underneath, and whether the same device keeps turning up under different accounts.

Try it

Run your own signup or checkout traffic through this: the free scanner returns the same verdict the API does.

Open the scanner

The tells, and what each one is worth

A verdict from /v1/evaluate carries a network block and, when the client SDK's event id was forwarded, a device block. "Engine default" below is the decision with no custom rules: a residential proxy, Tor, automation, emulator or antidetect signal each produce block alone, a VPN alone produces review, a datacenter exit alone stays allow. Why the decision and the score are separate axes explains the split.

TellField / reason codeWeight at a login
One device, many accountsdevice.linked_accounts, device.multi_account / multi_account_deviceEvidence at 2 or 3; block in double digits
Never-seen device on an established accountdevice.times_seen: 1, device.first_seen, device.returning: falseStep-up, never block alone
Browser misrepresents its enginedevice.antidetect / antidetect_browserBlock (engine default)
Scripted browserdevice.automation / automation_detectedBlock (engine default)
Residential proxy exitnetwork.proxy / proxy_detectedBlock (engine default); network.residential is a route, not a clearance
Tor exitnetwork.tor / tor_exit_nodeBlock (engine default)
Datacenter exitnetwork.datacenter / datacenter_asnEvidence; step-up with a second tell; exempt relay ranges
Consumer VPNnetwork.vpn / vpn_detectedEvidence on a returning device; step-up on a first-seen one
Clock disagrees with the exit countrytimezone.matches_ip: false with timezone.checked: true / timezone_mismatchStep-up; never blocks alone
Same device or account, many attemptsYour own counters (below), not an API fieldStep-up; block when it agrees with a device tell

Where the check goes: lookup, evaluate, then compare

Three things have to be true at once: you know which account is claimed, you have not spent a password verification, and you have issued nothing. That window is after the account lookup and before the hash compare.

Login handler order: account lookup, then evaluate, then hash compare 1. look up the account email → accountId 2. POST /v1/evaluate token + accountId 3. bcrypt.compare only if step 2 did not refuse linked_accounts exists here, and no hash round has been spent
Step 1 supplies the accountId step 2 needs; step 2 decides whether step 3 runs.

Ordering it that way buys two things. Passing the claimed accountId with the event id links the device to every account it tries, right password or not, and returns the count as device.linked_accounts, both stored as one-way hashes; that is why the worked model's ring shows within minutes. And refusing before the compare means the operator cannot make you pay for a hash per attempt. The hash is slow on purpose — the OWASP password storage cheat sheet (checked 2026-08-30) puts bcrypt's minimum work factor at 10 — so five thousand free compares is a small denial of service.

javascript
// POST /login: lookup, evaluate, then compare. Express + bcrypt.
// sentinel_* inputs come from the client SDK, which keeps the product's original name.
app.post('/login', async (req, res) => {
  const user = await users.findByEmail(req.body.email);          // 1. lookup
  const v = await evaluateLogin(req, user);                      // 2. evaluate; null on timeout
  if (!v) degraded.inc();
  const flags = await velocity(req, user, v);

  const ring = (v?.device?.linked_accounts ?? 0) >= 10;
  if (v?.decision === 'block' || ring) {
    return res.status(401).json(LOGIN_FAILED);                   // same body as a wrong password
  }

  const hash = user?.hash ?? DUMMY_HASH;                         // unknown email costs the same time
  const ok = await bcrypt.compare(req.body.password, hash) && user; // 3. compare
  if (!ok) return res.status(401).json(LOGIN_FAILED);

  const firstSeen = v?.device?.times_seen === 1;
  const vpnOnly = v?.reasons?.length === 1 && v.reasons[0] === 'vpn_detected';
  const reviewed = v?.decision === 'review' && !(vpnOnly && v.device?.returning);
  if (reviewed || firstSeen || flags.length || !matchesHistory(user, v)) {
    return res.json({ next: 'step_up', challenge: await startWebAuthnOrOtp(user) });
  }
  return res.json({ session: await sessions.create(user, v) });
});

async function evaluateLogin(req, user) {
  try {
    const r = await fetch('https://maskbreak.com/v1/evaluate', {
      method: 'POST',
      signal: AbortSignal.timeout(800),
      headers: { Authorization: 'Bearer sk_test_YOUR_TEST_KEY', 'Content-Type': 'application/json' },
      body: JSON.stringify({
        token: req.body.monocle,                  // network half: hidden input the client SDK adds
        fingerprintEventId: req.body.sentinel_fp, // device half: unlocks device.*
        accountId: user?.id,                      // required for device.linked_accounts
        tz: req.body.sentinel_tz,                 // adds the timezone block
      }),
    });
    return r.ok ? await r.json() : null;
  } catch {
    return null;                                  // fail open: "no opinion", never "safe"
  }
}

There is no ip field in that body: the address comes from the token, the device from the event id, and every read is optional-chained because the function returns null on a timeout. Test the routing first. The public sk_test_sandbox key answers test_clean, test_vpn, test_proxy, test_datacenter and test_tor with deterministic shapes, and your own sk_test_ key runs the full pipeline without billing or webhooks.

Velocity is the part you have to build

An evaluation describes one request. It cannot tell you the same device just tried forty accounts, because it does not know what your last forty requests looked like. That correlation is yours, and the key you count on decides whether it works: per IP, the pool gives every attempt a fresh address and the counter never fills. Count on what does not rotate.

javascript
const LIMITS = { device: 20, account: 10, ip: 30 };   // per 15 min; tune from your own logs

async function velocity(req, user, v) {
  const flags = [];
  for (const [kind, value] of [['device', v?.device?.visitor_id], ['account', user?.id], ['ip', req.ip]]) {
    if (!value) continue;
    const key = `login:${kind}:${value}`;
    const n = await redis.incr(key);
    if (n === 1) await redis.expire(key, 900);
    if (n > LIMITS[kind]) flags.push(`velocity_${kind}`);
  }
  return flags;
}

The device ceiling should sit high enough that a household sharing one laptop never reaches it. Telling a household from a resale is the same judgment from the other side: one account on many devices there, one device on many accounts here.

What "normal for this account" means

History turns a generic verdict into one about the person logging in. Store three things on every successful login: device.visitor_id, country and the hour. A login matching all three is boring; one matching none, on an account that has logged in from the same city for two years, earns a second factor even when every signal is clean. That is matchesHistory().

Read the geographic tell with care. timezone.matches_ip looks like the perfect signal until you read timezone.checked: on a VPN, proxy, Tor or datacenter exit a mismatch is expected and the API raises nothing. When checked is false, the mismatch describes the tunnel, not the person.

The four actions after login

Look again at the 1:00 row. The damage on a taken account happens after authentication: an email, phone or password change, a new payout destination or stored card. Those four forms get the same call the login got, with the session's accountId and a fresh token from the page. A different visitor_id from the one stored at session start, or a reason code that was not there before, is a step-up on the action, whatever the login looked like.

The response ladder

A blanket block on anything unfamiliar strands real customers, and a blanket allow is where you started. Three outcomes, with the middle one doing most of the work.

OutcomeEvidence it needsWhat the customer sees
Allowdecision: allow, device.returning: true, history matches, no counter tripped. A returning device with vpn_detected as its only reason lands here too.Nothing. This is most logins.
Step upAny one of: decision: review, times_seen: 1, a velocity flag, a history mismatch, timezone_mismatch with checked: true, a datacenter exit plus a second tell.A second factor: a passkey first, which NIST classes as phishing-resistant, then OTP.
Blockdecision: block, or independent tells agreeing: a proxy exit plus a first-seen device plus a velocity flag. One tell blocks alone only when it is linked_accounts in double digits, because that is not a proxy for the attack, it is the attack.The same failure response as a wrong password.

The last cell is not a detail. A refusal never reads the password, so it cannot leak which pairs were valid; what a distinguishable refusal leaks is that a defense fired, a cue to rotate exits and retry — hence the same failure shape as a wrong password. On the path that does read it, the OWASP authentication cheat sheet (checked 2026-09-01) prescribes one generic response "regardless of whether: The user ID or password was incorrect. The account does not exist. The account is locked or disabled" and warns that a processing-time gap between those cases lets an attacker "mount a time-based attack"; the handler's DUMMY_HASH is that advice applied.

Two exemptions belong in the ladder before it goes live. The first is privacy relays. Apple's guidance for web servers on iCloud Private Relay (checked 2026-08-30) publishes the egress ranges as a CSV and asks operators to treat them "like larger carrier-grade NAT or enterprise IP addresses". An exit on that list is a customer on an iPhone and should never reach the block tier on network evidence. The second is the shared machine: a family laptop, an office desktop, a refurbished phone, each linked to a few accounts legitimately. That is why linked_accounts at 2 or 3 is noise and double digits is a finding.

Fail open, and alert on the rate. Login is availability-critical in a way signup is not; fail closed on a vendor timeout and you have locked out every customer at once. Let the login proceed, mark it degraded, and alert on the proportion of degraded logins in a window. Store reasons and risk_score on the attempt record; when you ask later whether the step-up tier is too tight, the reason codes are the only thing that can answer.

What it costs you

The step-up has a list price. Twilio Verify (checked 2026-08-30) charges $0.05 per successful verification plus the channel fee, $0.0083 per US SMS. A passkey challenge costs nothing per use. As a worked model, a service stepping up 2,000 logins a day by SMS pays about $117 a day; over passkeys, nothing. That budget is why the tier fires on evidence rather than on everything unfamiliar; send codes to whoever asks and SMS pumping is that invoice, driven on purpose.

The takeover you did not stop has a list price too. Stripe's dispute fee schedule (effective 2025-06-17, checked 2026-08-30) sets the dispute received fee at $15 in the US and €20 in Estonia; the disputes documentation says that outside Mexico "the fee for receiving a dispute is non-refundable", with a further $15 or €20 to counter, returned only on a win. A stored card used from a taken account produces exactly that dispute. The refused-attempt hash compares and the "I did not change my email" support tickets appear on no invoice; the two costs that do are worth knowing.

Checklist

  • Account lookup, then /v1/evaluate, then the password compare. Refuse before the hash.
  • accountId and the device event id both sent, or device.linked_accounts never appears; tz forwarded.
  • Velocity counted on visitor_id and account before IP; the counter that tripped is logged.
  • Step up on review, first-seen devices and history mismatches; block on agreement or a double-digit device ring.
  • vpn_detected alone on a returning device changes nothing; timezone.matches_ip read only when checked is true.
  • Route on device.antidetect and the reason code, never on tampering_score.
  • Private Relay ranges and shared machines exempted from the block tier; the same failure shape for a wrong password and for a refusal.
  • Email, phone, password and payout changes evaluated with the same call; fail open, mark degraded, alert on the rate. The engine defaults these routes rely on are on the account takeover page, and the free tier covers the sandbox tokens.

FAQ

Questions people ask

How do you detect account takeover before the login succeeds?
Run the evaluation between the account lookup and the password compare, with the claimed accountId and the device event id in the request. The verdict then carries device.linked_accounts, times_seen and the network class, and because every attempt links the device to the account it claimed, a device working a stolen list shows a rising count before any password succeeds.
Should I block logins from a VPN?
Not on a device the account has used before. A consumer VPN alone returns decision review and vpn_detected, which is ordinary privacy hygiene on a returning device. Treat it as evidence, step up when the device is first-seen or a counter has tripped, and block only when independent tells agree, such as a proxy exit plus a new device plus velocity.
Does MFA stop credential stuffing?
It stops most of it. NIST SP 800-63B classes OTP and out-of-band codes as not phishing-resistant, so a real-time phishing page can relay them; passkeys and security keys are the phishing-resistant option. MFA belongs in the ladder as the step-up, and the evaluation decides when to ask, so most customers never see it.
What is the difference between credential stuffing and account takeover?
Credential stuffing is the replay of stolen email and password pairs to find which ones work on your site. Account takeover is what happens with the pairs that do: a login from a machine the account has never seen, followed by an email or payout change. Stuffing is the first stage; takeover is the outcome.
Get started

Put the check where the attack enters

One call before signup, login or checkout returns decision, risk_score and the reasons behind them. The free tier is 1,000 requests an hour, no card required. Start with <a href="/vpn-detection">VPN detection</a> and <a href="/proxy-detection">proxy detection</a>, the network layer most attacks lean on.

Get started freeRead the API docs