Portreeve
explainer · 3 Sept 202612 min read

Account takeover detection that doesn't lock out real users

How account takeover happens at a SaaS login, which signals separate a stolen credential from a traveling customer, and why a challenge beats a block.

Account takeover at a SaaS login rarely looks like a break-in. It looks like a correct password. The attacker already has the credential, so the login succeeds, the session is issued, and everything downstream treats it as the customer. Detection has to happen in the few hundred milliseconds between "password matches" and "session issued", using signals other than the password.

This post covers how the credential got stolen, what a login has to be compared against to spot a takeover, and why the answer to an ambiguous login is a challenge rather than a block.

How credential stuffing and phishing take over accounts

The Verizon 2025 Data Breach Investigations Report ranks credential abuse as the leading initial access vector, at 22% of the 12,195 breaches it analyzed, ahead of vulnerability exploitation at 20%. Three routes account for most of that, and they leave different fingerprints at your login.

Credential stuffing. Someone takes an email:password list from another site's breach and replays it against yours. Password reuse is what makes this work. Cloudflare measured logins across the sites it fronts between September and November 2024 and found that 41% of successful logins used a password already present in a known breach, and that 95% of the attempts using leaked passwords came from bots. The hit rate per attempt is small, typically 0.1 to 2 percent according to F5 Labs, which is why the tooling is built for volume. OpenBullet-style frameworks take a site-specific config, a combolist, and a proxy pool, and run for days. F5 puts the price of the lists at anywhere from free to tens of dollars.

The proxy pool is the part that breaks naive defenses. Okta's threat research team watched a campaign from April 19 to April 26, 2024 that routed through residential proxy services, so the traffic came from ordinary home and mobile IPs rather than a datacenter range you could blacklist. Each IP makes a handful of attempts and moves on. Per-IP rate limits never fire.

Phishing. A lookalike login page collects the password and, increasingly, relays the MFA code in real time. The attacker ends up with a working credential and a fresh session from a device you have never seen.

Session and key theft. Infostealer malware on the customer's laptop exports browser cookies and any API keys sitting in dotfiles. No login event happens at all on your side, or the attacker replays a session cookie that was issued after MFA already passed.

Why a SaaS account is worth taking

Nobody stuffs credentials against a product with nothing inside. The targets are accounts that hold something convertible: a stored payment method, prepaid credits, an API key with a spend limit, or a sending reputation.

The AI-tools version of this is well documented. When a scraper claimed to have pulled over 1,000 working OpenAI API keys from public Replit projects, the keys were handed out as free GPT-4 access on Discord, and a screenshot of one hijacked account showed $1,039.37 of usage for the month. On the OpenAI developer forum, animeshs described logging in to find their paid account now owned by a stranger with an outlook.com address, themselves demoted to reader, and their card still on the billing page they could no longer reach.

If your product has a free tier with credits, a metered API, or a Stripe customer with a saved card, you are on the list. The same accounts that get taken over are the ones that get used for card testing once the attacker is inside.

The account baseline

You cannot score a login as unusual without a record of what usual looks like for that specific account. Global rules ("block logins from Nigeria") are how you lose your customer in Lagos while missing the attacker on a Comcast residential proxy in Ohio.

A baseline for one account is a small set of histories:

  • Devices. The set of browser fingerprints or device tokens that have successfully logged in, with first-seen and last-seen timestamps.
  • IP families. ASNs and rough geography rather than raw IPs, which rotate. A user who has only ever come from one mobile carrier and one home ISP in one metro has a tight baseline. A consultant who logs in from airport wifi in six countries has a loose one, and a loose baseline should make you more tolerant, not less.
  • Time of day. Most accounts log in inside a window. A 3 a.m. login in the account's home timezone is a soft signal on its own and a hard one combined with a new device.
  • Velocity. Failed attempts against this account in the last hour, and successful logins across distinct devices in the last day.
  • What the account holds. Credits balance, saved card, API keys, admin role. This does not change whether a login is suspicious. It changes how much a false negative costs, which should change your threshold.

A baseline needs history, and a new account has none. Treat the first few logins as baseline-building: nothing to compare against, so you lean on network and device intelligence alone and accept a wider margin.

Scoring a login

The password check is a boolean. The takeover check is a comparison of the login's context against the baseline, and the useful output is a combination of signals rather than any single one.

Consider three logins with the same correct password:

  1. Known device, new IP on a new ASN, same country. This is a customer on a different network. Allow.
  2. Unknown device, known ASN and city, business hours. Probably a new laptop. Allow, or challenge if the account is high-value.
  3. Unknown device, residential proxy ASN, a country the account has never logged in from, forty minutes after a successful login from the home city. Challenge at minimum, block if the device also appears on other accounts.

The third case is impossible travel. Be precise about what makes it strong. Impossible travel on its own is noisy: split-tunnel VPNs, mobile carrier gateways that geolocate to the wrong city, and CDN egress all produce it for legitimate users. It becomes a strong signal when it co-occurs with an unrecognized device and a proxy or datacenter IP classification. The device is the signal that is hardest for a stuffing operation to fake per account, because the attacker has never had access to the real customer's browser.

Device recognition is also the signal that catches the case password checks cannot. A stuffing run that finds a valid password lands on your site from a headless browser or an anti-detect browser with a rotated fingerprint. As bobbiechen put it in a Hacker News thread on credential stuffing, those browsers are "generally detectable by mismatches in various attributes compared to the 'real' browser whose user agent they are spoofing". An unrecognized device on an account with a two-year device history is a different event from an unrecognized device on an account created yesterday.

Two signals are weaker than they look:

  • Raw IP. Shared egress means one office, one university, or one carrier NAT can put thousands of humans behind one address. Counting per IP produces both false positives and, on residential proxies, false negatives. OWASP's credential stuffing cheat sheet is blunt that IP blocking should not be used as the sole or primary defense.
  • Failed-attempt counts against one account. Stuffing tries each credential once. A single failure followed by success on the next attempt from a different IP looks like a typo. What separates it is that the success came from a device the account has never seen.

This is the part I built Portreeve for. You send one login event with the device token and IP, and the engine compares them against the account's own history, returning allow, review, or block with reason codes in under 100 ms. review never blocks on Portreeve's side: the event enters the dashboard's review queue, and if it is later denied a signed webhook tells your server to revoke the session. On a login, the natural way to wire that is as a step-up challenge, and the verdict handling guide covers mapping each verdict to an action.

The shape of that handler in a Node backend:

import { Portreeve } from "portreeve";
const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY!);

const result = await portreeve.verdict({
  event_type: "login",
  external_user_id: user.id,
  ip: req.ip,
  device_token: req.body.device_token, // from @portreeve/browser
  email: user.email,
});

if (result.verdict === "block") return genericFailure(res);
if (result.verdict === "review") return requireStepUp(res, user, result.reasons);
return issueSession(res, user);

The device token comes from the browser snippet, which is set up in a few lines. Without it, you are scoring on IP and email alone, which is exactly the blind spot a residential proxy exploits.

Challenge, don't block

The standard advice is to require MFA for everyone, or to hard-block anything that looks off. Both are wrong for most small SaaS products, for different reasons.

Blanket MFA taxes every login to catch a small fraction. Google's study of its own login challenges, covering over 350,000 real hijacking attempts, found that device-based challenges blocked 100% of automated hijacking attempts and over 94% of phishing-based ones. The same study found that 52% of legitimate users initially failed a challenge, even though 97% got in shortly after. Challenges are near-perfect against automation and expensive for humans. You want to spend that cost only on logins where the baseline comparison says something is off.

Hard blocks are worse. A blocked login gives the customer nothing to do except email support, and a traveler who gets blocked at a hotel at midnight does not email support. They cancel. You will also never learn about the false positive, because a blocked user produces no signal. A challenged user either passes, which teaches you the new device, or fails, which confirms the takeover.

So the response ladder is:

  • Allow for a login inside the baseline, or a single soft signal on a low-value account.
  • Challenge for anything ambiguous: unrecognized device, new ASN family, off-hours, impossible travel. The challenge should be device-based where you can manage it (a push prompt or a link to a previously seen device), an emailed code where you cannot. Pass adds the device to the baseline. Fail revokes the pending session.
  • Block only for high-confidence shapes: a device already tied to confirmed abuse on other accounts, a login from a cluster you have convicted, or a stuffing pattern across many accounts from one device. Return a generic failure so the attacker cannot tell which control fired.

Add friction inside the failure response as well as at the gate. In the same Hacker News thread, tracker1 described adding "a random 1-4 second delay returning from failed logins regardless of the reason", which raises the cost of every stuffing run without touching a single real user's success path.

Challenging is free only if your challenge channel works. If the attacker took the account through phished email credentials, an emailed code goes to them. Prefer a device you have seen before, and fall back to email only for accounts without one.

What to log today so account takeover detection is possible tomorrow

Most SaaS login tables store user id, timestamp, and IP, which is enough to reconstruct almost nothing. To build a baseline you need, on every login attempt, successful or not:

  • The device token or fingerprint, and whether it was previously seen on this account.
  • IP, plus its ASN and a classification: residential, mobile, datacenter, VPN, proxy, Tor. Resolve at write time; the answer for an IP changes.
  • Coarse geolocation at country and city level. Never store more precision than you will use.
  • User agent, and whether it is consistent with the device fingerprint.
  • Outcome: password failed, password ok, challenged, challenge passed, challenge failed, session issued.
  • Session id issued, so you can revoke the right one later.

Keep the raw rows for long enough to learn a baseline and then reduce them. Hashing identity keys from day one and scrubbing raw payloads after a fixed window is the pattern that keeps this defensible under a data request. The event payload docs show what a login event carries, which doubles as a checklist for your own table.

The most useful thing in that list is the outcome column. Without it, a successful stuffing hit is indistinguishable from a normal login in your own logs, and you find out from the chargeback.

After a confirmed takeover

When a challenge fails, a customer reports it, or a later review comes back deny, the response is the same three moves, in order.

Revoke sessions. All of them for that account, including the one the real customer is using, and force a password reset through a channel the attacker does not control. GitHub's own guidance is to review active sessions and revoke any you do not recognize; as the operator you do it on the customer's behalf. If your sessions are stateless JWTs with no revocation list, this is the moment you discover that.

Rotate keys. Any API key, webhook secret, or personal access token on the account is burned. Rotate them, and if the account holds a saved card or credits, freeze spend until the owner is back. Stripe's docs on rotating API keys are the model: the replacement key is ready the moment you rotate, and you choose whether the old one dies now or after a grace period of up to seven days.

Notify. Tell the customer what you saw, in plain terms: the device, the rough location, the time, and what you did about it. Then feed the confirmation back into whatever you use to score logins, so the device and any linked accounts carry the conviction forward. This is what makes the next attempt from the same tooling a block rather than another challenge.

Then look sideways. A device that took one account has usually tried others. Query your login log for that device token across all accounts in the last 30 days. The ones where it passed a challenge are the ones to worry about.

If you want the login comparison without building the baseline store yourself, Portreeve's free tier covers 1,000 screened events a month with no card.

← Back to all posts