To detect account takeover at login you need something your password check does not have: a memory of the devices this account normally signs in from. This walks through adding that to an Express login handler, so the small slice of sign-ins that look like takeover get a one-time code and everyone else goes through untouched.
Nobody gets a hard lockout.
Three outcomes at POST /login when you are done:
- Normal sign-in. Password verifies, risk check returns
allow, session issued. No change from today. - Suspicious sign-in. Unrecognized device, datacenter IP, a burst of devices hitting one account. The user gets an email code or a TOTP prompt before the session is issued.
- High-confidence takeover. Generic "invalid credentials" response, no session, no hint to the attacker about which half of the pair was right.
The tool I use for this is Portreeve, the abuse firewall I built. One API call at login returns allow, review, or block with reason codes in under 100 ms, and review never blocks the user: the flow proceeds, the event lands in a review queue, and a later deny arrives at your server as a signed webhook so you can revoke. That property is why this tutorial has a step-up branch instead of a lockout branch.
Before you start
You need Node 18.17 or later, an Express-style server, a working password login, somewhere to hold a short-lived code (Redis, or a login_challenges table), a transactional email sender, and a Portreeve account for the API keys.
Work in test mode the whole way through. Test mode is fully isolated from live, so nothing you do while building touches your real event history.
Step 1: Collect a device token on the login form
The account baseline is "has this account used this device before". You cannot answer that from an IP, so the browser has to hand you something stable.
Start the collection when the form mounts and await it at submit. That way the token is already resolved by the time anyone clicks.
// login-form.js
import { collectDeviceToken } from "@portreeve/browser";
// Kicks off at module load, when the form mounts.
const devicePromise = collectDeviceToken(process.env.NEXT_PUBLIC_PORTREEVE_KEY);
async function submitLogin(email, password) {
const device_token = await devicePromise;
await fetch("/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password, device_token }),
});
}
collectDeviceToken takes your publishable key (pk_test_... while you build) and resolves to a signed, short-lived token or null. It never throws, so it cannot break your login form. Tokens live 15 minutes, which is longer than anyone spends typing a password.
If it resolves to null, send the request anyway. A missing token weakens the verdict without failing it, and an expired one is dropped silently while the verdict still returns 200. The device fingerprinting docs cover the token lifetime and the usual reasons a device signal does not fire.
Step 2: Add the login risk check after the password verifies
Order matters here. Screen after the password check, never before it.
import { Portreeve } from "portreeve";
const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY!);
app.post("/login", async (req, res) => {
const { email, password, device_token } = req.body;
const user = await verifyPassword(email, password);
if (!user) return res.status(401).json({ error: "Invalid credentials" });
const result = await portreeve.verdict({
event_type: "login",
external_user_id: user.id,
ip: req.ip,
device_token,
email: user.email,
});
// ...handle result
});
Both external_user_id and device_token are doing work, and neither is sufficient alone.
external_user_id is what the baseline is keyed to. Without it there is no account to compare against, and a login from a brand-new machine looks identical to the tenth login from a laptop this account has used for a year. device_token is the other half. It is what turns "unrecognized device" into a fact rather than a guess. Send one without the other and your verdict falls back to IP and email quality, which is a much weaker read.
That is also why the check belongs after the password verifies. Before it, you do not know whose account this is. You would be screening an email address someone typed.
ip is required for login. If you sit behind a proxy, set app.set("trust proxy", 1) so req.ip is the client address and not your load balancer. Otherwise every login you screen arrives from one egress IP and looks like a single very busy visitor.
Step 3: Step up on challenge, block only on block
if (result.verdict === "block") {
await auditLog(user.id, "login_blocked", result.id, result.reasons);
return res.status(401).json({ error: "Invalid credentials" });
}
if (result.recommended_action === "challenge" && !result.degraded) {
return startStepUp(res, user, result.id);
}
await issueSession(res, user);
return res.json({ ok: true });
Three things to notice.
block returns the same message and the same status code as a wrong password. An attacker running a list learns nothing from a response they cannot distinguish, and a real user who lands here goes to your password reset flow, a path you already support.
recommended_action is the field you branch on. It is optional, and it arrives as "challenge" on a login the engine wants stepped up. Read it rather than inferring a challenge from the verdict string: a review with no recommended_action means let them in and put the event in the queue, and treating that as a prompt is how you end up challenging people who did nothing unusual.
degraded: true means the SDK failed open inside its 400 ms budget and returned allow with a *_failopen reason code, because the API timed out or was down. You have no signal, so do not manufacture friction out of it. The handling verdicts reference has the full matrix, including the client-side fail-closed option if you would rather stop logins than let an unscored one through.
Step 4: Wire step-up authentication in Node
If the user has TOTP enrolled, prompt for it. If not, an emailed six-digit code is enough, and it is the same channel the attacker would need to compromise anyway.
Key the pending challenge to an opaque id you generate, not to the user id. If the verify endpoint trusts a user id from the request body, anyone can name someone else's account.
import { randomInt, randomUUID } from "node:crypto";
async function startStepUp(res, user, eventId) {
const code = randomInt(0, 1_000_000).toString().padStart(6, "0");
const challengeId = randomUUID();
await redis.set(
`stepup:${challengeId}`,
JSON.stringify({ userId: user.id, code, eventId, attempts: 0 }),
{ EX: 600 }
);
await sendLoginCode(user.email, code);
return res.status(200).json({
step_up: user.totpEnabled ? "totp" : "email_code",
challenge_id: challengeId,
});
}
The verify endpoint looks up that id, compares the code in constant time, caps attempts at five, and deletes the record on success:
app.post("/login/verify", async (req, res) => {
const pending = await consumeChallenge(req.body.challenge_id, req.body.code);
if (!pending) return res.status(401).json({ error: "Invalid code" });
await portreeve.feedback(pending.eventId, "false_positive");
await issueSession(res, pending.user);
res.json({ ok: true });
});
Reporting false_positive records that the flag was wrong on an event a real user cleared. Feedback is idempotent per event and outcome, so a retry costs you nothing.
Clearing an emailed code is evidence, not proof. Someone who already owns the mailbox clears it too, which is why a confirmed takeover later gets reported against the account.
Step 5: Verify with a fresh browser profile
- Sign in normally three or four times from your usual browser. This is what builds the account's device history.
- Open a brand-new browser profile. An incognito window in the same profile is not enough; you want a genuinely different fingerprint.
- Sign in with the same credentials. You should get a
step_upresponse instead of a session, and the code should arrive in your inbox. - Open the dashboard and find the event. Check that
external_user_idmatches, that the device is marked unrecognized, and that the reason codes say what you expect. The reason codes reference lists what each one means. - Enter the code. Confirm the session is issued and the event now carries your
false_positivefeedback.
If step 3 returns a plain allow, the usual cause is device_token arriving as undefined because the client posted before the collect call resolved. Log the token length server-side for one deploy and check.
Logging, and what to do after a confirmed takeover
Write result.id into your auth log line next to the user id and timestamp. When a support ticket says "someone was in my account on Tuesday", that id is how you find the event, its reason codes, and the cluster it belonged to.
When you confirm a takeover, report it against the account rather than one event:
await portreeve.feedback(
{ kind: "external_user_id", value: user.id },
"confirmed_abuse",
"ATO confirmed via support ticket 4412"
);
Accounts are linked across hashed identity keys, so confirming abuse on one marks the whole linked cluster. If the same actor is sitting on other accounts of yours through a shared device or card fingerprint, they come along.
Then wire the review webhook, so a login you deny in the dashboard kills the session your code already issued:
app.post("/webhooks/portreeve", express.raw({ type: "application/json" }), async (req, res) => {
const event = Portreeve.verifyWebhook(
req.body,
req.get("portreeve-signature"),
process.env.PORTREEVE_WEBHOOK_SECRET!
);
if (event.type === "review.resolved" && event.resolution === "denied") {
await revokeSessions(event.external_user_id);
}
res.sendStatus(200);
});
Deliveries retry until they get a 2xx, so make revokeSessions idempotent. The full revoke path is in handling a review denial with a webhook.
Going to production
Swap to live keys. The publishable key belongs in your client bundle; it is meant to be public. The secret key stays server-side.
If your login handler has a retry path that could screen the same attempt twice, pass a dedupeKey on verdict() so the repeat returns the original verdict instead of opening a second event against your quota.
Decide up front what you do with an abandoned step-up. Someone who requests a code and never enters it is a signal, and it belongs in the queue you read on Monday.
Where this goes wrong
Requiring MFA on every login. This is the advice you will get everywhere, and for a consumer product with a free tier it is usually wrong. The OWASP cheat sheet says it plainly: multi-factor authentication can be combined with other techniques "to require the 2nd factor only in specific circumstances where there is reason to suspect that the login attempt may not be legitimate". NIST treats it the same way, noting that "authentication from an unexpected geolocation or IP address block (e.g., a cloud service) might prompt the use of additional risk-based controls". The blanket version also buys less than you would hope. Listing what failed at their fintech on Hacker News, helloworld4728 put phone-number 2FA in the didn't-work column: it "significantly slowed legitimate user access but still didn't fully stop credential stuffers".
Blocking on review. review exists so the engine can flag aggressively without hurting anyone. Treat it as a block and you have thrown away the safety margin, and you are back to tuning a threshold against your own support inbox. Step up on challenge, let a bare review through, act on the webhook.
Skipping the device token. Without it you are left with IP, and IP is a soft signal for a reason. Residential proxies start at $1.75 per GB, which is nothing for something as small as a login attempt, and OWASP is blunt that blocking addresses "should not be used as the sole or primary defense due to the ease in circumvention". A user on a train changes IP every few minutes and their device does not change at all.
Read the rest of that Hacker News comment against this, though, because the same attackers ran "advanced fingerprint-shifting browsers and residential proxy ips". A device token is not a wall. What it buys is a real comparison against one account's own history, and a key that links accounts once you confirm abuse on any of them. The longer argument for why device and cluster beat IP is in how account takeover actually runs.
Get a test key and run the fresh-profile check against your own login: sign up free, 1,000 screened events a month, no card.