- Login is one of six endpoints that hand over an account; password reset is the softer and more valuable one.
- Write the API call once, with a short timeout and fail-open, and route on decision plus first-seen device at each call site.
- Bind a reset token to the device that requested it and refuse completion from another.
- Hold contact and payout changes on review instead of refusing them, and notify the old address.
On this page
Every account-takeover write-up, including ours, spends most of its words on the login form. That is where credential stuffing lands, so it is where the first check goes. It is also the only endpoint most teams protect, and an attacker who has read the same write-ups knows it. The list of endpoints that hand over an account is longer than one, and the ones after login are quieter, less rate-limited and worth more.
This is the map: one helper, six call sites, and the rule for each — what to do on block, what to do on review, what a first-seen device means at that particular endpoint, and what happens when the API is down. The credential-stuffing post covers the ordering inside the login handler; the thresholds post covers bands. Nothing here repeats them.
One helper, called six times
The mistake that makes this expensive is writing the API call six times. Write it once, give it a timeout it will actually hit, make it fail open, and have it return a shape the call sites can route on without knowing a vendor exists.
// lib/assess.js
const Sentinel = require('@sentinelsup/sdk');
const sentinel = new Sentinel({ apiKey: process.env.SENTINEL_KEY, timeoutMs: 800 });
// Every protected endpoint calls this with the two fields the client SDK
// injects (monocle = network token, sentinel_fp = device event id) and
// the account the request is about. `action` is for your own logs.
async function assess(req, { accountId, action }) {
const { monocle, sentinel_fp } = req.body;
const t0 = Date.now();
try {
const v = await sentinel.evaluate({
token: monocle,
fingerprintEventId: sentinel_fp,
accountId,
});
req.log.info({ action, accountId, decision: v.decision, reasons: v.reasons, ms: Date.now() - t0 });
return {
decision: v.decision, // allow | review | block
newDevice: v.device?.times_seen === 1, // first time this machine was seen on your key
device: v.device?.visitor_id || null,
linked: v.device?.linked_accounts || 0,
reasons: v.reasons || [],
degraded: !!v.degraded,
};
} catch (err) {
// Fail open. "No opinion" is not "safe", so call sites treat a
// degraded verdict as allow-with-a-note, never as clean.
req.log.warn({ action, err: err.message }, 'assess unavailable');
return { decision: 'allow', newDevice: false, device: null, linked: 0, reasons: [], degraded: true };
}
}
module.exports = { assess };
The same thing without an SDK, for a Python service. The timeout is the important line; a fraud check that can take ten seconds is a denial of service you run against yourself.
# app/assess.py
import os, requests
DEGRADED = {"decision": "allow", "new_device": False, "device": None, "linked": 0, "reasons": [], "degraded": True}
def assess(form, account_id=None):
"""form is the parsed request body; the client SDK put monocle and sentinel_fp in it."""
try:
r = requests.post(
"https://maskbreak.com/v1/evaluate",
json={"token": form.get("monocle"), "fingerprintEventId": form.get("sentinel_fp"), "accountId": account_id},
headers={"Authorization": f"Bearer {os.environ['SENTINEL_KEY']}", "Content-Type": "application/json"},
timeout=0.8,
)
r.raise_for_status()
v = r.json()
d = v.get("device") or {}
return {
"decision": v.get("decision", "allow"),
"new_device": d.get("times_seen") == 1,
"device": d.get("visitor_id"),
"linked": d.get("linked_accounts", 0),
"reasons": v.get("reasons", []),
"degraded": bool(v.get("degraded")),
}
except requests.RequestException:
return dict(DEGRADED)
Two things the helper deliberately does not do. It does not cache across accounts — a verdict is about one request from one device for one account, and a cache keyed on the device would let a stuffing run pay for one evaluation and reuse it. And it does not translate review into anything; what review means is different at every endpoint below, which is the point of the post.
1. Login
Account lookup, then assess, then the password compare, in that order, so the verdict carries an accountId (which is what returns linked_accounts) and no bcrypt round is spent on a request that will be refused anyway. The rule:
- block: the exact 401 a wrong password produces. Same status, same body, same rough timing. A different response is a validation oracle, and a verified credential list is worth more per line than the accounts.
- review, or a first-seen device on an existing account: an emailed code before a session is issued. It converts far better than a CAPTCHA because it only fires for the few percent that earned it.
linkedin double digits: that is the run itself, in progress, and it deserves an alert and a look at what else the device touched, not only a 401.- degraded: issue the session, log it, and alert on the rate of degraded logins. Login is availability-critical; fail closed here and you have locked out every customer, including the ones with support contracts.
Wire it into your own app: a free key returns decision, risk_score and reasons for every visit, 1,000 requests an hour, no card.
Get an API key2. Password reset, twice
Reset is the softest target you own, because it needs no password. An attacker with a list of email addresses and access to a few mailboxes, or a way to intercept the message, gets a fresh, legitimate session and the real owner locked out. It is also the endpoint least often rate-limited by anything other than IP, which a residential pool makes free. There are two endpoints here, and both get the call.
The request. Look the account up, call assess with its id, and answer the same sentence whether or not the account exists: “If an account exists for that address, we have sent an email.” On block, say the sentence and send nothing. On review, send the email but mark the token as requiring step-up. Count attempts against device, not the IP.
The completion. This is where the takeover actually happens, so this is where the second check goes. Bind the reset token to the device that requested it, and refuse completion from another one.
// routes/reset.js
app.post('/api/reset/request', resetRateLimit, async (req, res) => {
const user = await findUserByEmail(req.body.email);
const v = await assess(req, { accountId: user?.id, action: 'reset.request' });
if (user && v.decision !== 'block') {
// The token remembers which machine asked for it.
const token = await issueResetToken(user.id, { device: v.device, stepUp: v.decision === 'review' });
await sendResetEmail(user, token);
}
// One sentence for every outcome: no account, blocked, sent.
return res.json({ ok: true, message: 'If an account exists for that address, we have sent an email.' });
});
app.post('/api/reset/complete', async (req, res) => {
const t = await loadResetToken(req.body.token);
if (!t) return res.status(400).json({ error: 'Invalid or expired link' });
const v = await assess(req, { accountId: t.userId, action: 'reset.complete' });
// A link requested on one device and opened on another is the
// interception case. Refuse it the same way an expired link is refused.
const otherDevice = t.device && v.device && t.device !== v.device;
if (v.decision === 'block' || otherDevice) {
return res.status(400).json({ error: 'Invalid or expired link' });
}
if (v.decision === 'review' || t.stepUp || v.newDevice) {
await sendResetCode(t.userId); // a second factor by email or phone
return res.json({ next: 'enter_code' });
}
await setPassword(t.userId, req.body.password);
await revokeAllSessions(t.userId); // the attacker's, if any, included
await notifyOldContact(t.userId, 'Your password was changed');
return res.json({ ok: true });
});
Two details carry most of the value. Revoke every session on a completed reset, because a reset that leaves the attacker’s existing session alive has fixed nothing. And send the “your password was changed” notice to the contact details the account had before this request, not the ones it has now.
3. MFA enrolment, disable, and recovery codes
Within minutes of a successful takeover, the attacker enrols their own authenticator so the real owner cannot get back in. Disabling MFA is the same move from the other side. Both are post-authentication actions on a session that already exists, which means the check here catches the session that was stolen rather than the login that was stuffed, and it is the only check that does.
- block: refuse the change and end the session. A blocked verdict on an authenticated session is a replayed cookie, not a customer.
- review, or a first-seen device: require the password again plus the existing second factor before the change. Enrolment on a new device without the old factor is exactly the lockout the attacker wants.
- Recovery-code use from a first-seen device: allow it — that is what the codes are for — but send the notice, and put the account under review for 24 hours so the contact and payout changes below hold instead of applying.
- Always: an email with a one-click “this was not me” that revokes every session and freezes the change. It is the cheapest control on this list and the one most teams skip.
4. Email, phone and payout-detail changes
This is the money moment. A taken-over benefit account, marketplace account or bank account is worth nothing until the payout details point somewhere else, and the email change is how the attacker keeps the notices from reaching the owner. The rule is different from everywhere else: on review, do not refuse — hold.
- block: refuse, notify the old address, and alert. Pass
accountId:linked_accountsclimbing across payout changes is a mule network being wired up, and it looks like nothing else. - review, or a first-seen device: accept the change into a 24-hour hold. Notify the old address and number with a cancel link. A real customer who moved house loses nothing but a day; an attacker loses the account.
- degraded: hold anyway. This is the one endpoint where “no opinion” should cost a day, because the cost of being wrong is someone’s money.
5. Session refresh and remembered devices
A stolen session cookie is genuine; the machine replaying it is not. The cheapest place to notice is token refresh, which every session does anyway. Record device when the session is issued. At refresh, call assess once per session-hour (not per request) and compare.
- Device changed on a remembered session: a replay until proven otherwise. Force a fresh login; keep the old session revoked.
- block: end the session.
- review: require re-authentication before the next sensitive action, but do not log the user out of a page they are reading.
- degraded: keep the session and re-check at the next refresh. A vendor blip must not log everyone out.
Cache the verdict on the session id, never on the device id. The point of the check is that the device might not be the one the session was issued to.
6. OAuth callbacks and API-token creation
A social-login callback is a login, and the account behind it can be taken over on the identity provider’s side or through a linking flaw on yours. Run assess in the callback handler with the linked account’s id and apply the login rule.
Personal access tokens and API keys are the persistence step: an attacker mints one so they survive the password reset the owner will eventually do. Treat creation as a sensitive action — re-authenticate on review or a first-seen device, refuse on block, and email a notice naming the token prefix. Then make the password reset above revoke every token created in the previous 24 hours. Most incident write-ups that end with “the attacker retained access for weeks” end that way because of this endpoint.
The rule for each endpoint
| Endpoint | block | review, or first-seen device | API unavailable |
|---|---|---|---|
| Login | The wrong-password 401, byte for byte | Emailed code before the session | Issue the session, log it, alert on the rate |
| Reset request | The “we sent an email” sentence, no email | Send, token bound to the device, step-up flagged | Send and bind |
| Reset completion | Refuse as an expired link | Second emailed code | Allow if the device matches the token |
| MFA enrol / disable, recovery codes | Refuse and end the session | Password plus the existing factor | Allow and notify |
| Email, phone, payout change | Refuse, notify the old address, alert | 24-hour hold with a cancel link to the old address | Hold |
| Session refresh | End the session | Re-authenticate before the next sensitive action | Keep the session, re-check next refresh |
| OAuth callback, token creation | Refuse | Re-authenticate, notify | Allow and notify |
What not to do
- Do not block on a VPN alone. The API returns
reviewfor it, and a meaningful share of real customers use one. Routereviewto the step-up for that endpoint. - Do not let a blocked request look different from a failed one. Same status, same body, same rough latency, on login and on reset request. Decide the step-up after both checks have run, not between them.
- Do not evaluate every request. Once per session-hour at refresh, plus each sensitive action. More than that costs latency on pages that carry no risk and buys nothing.
- Do not fail closed on login or refresh. Fail closed only where the cost of being wrong is money, which on this list is the payout change, and there it is a hold rather than a refusal.
- Do not skip
accountId.linked_accountsis the one signal that is literally the attack rather than a proxy for it, and it is absent without the id. - Do not store the raw verdict and nothing else. Persist
reasonsandrisk_scoreon the attempt or the change record; six weeks later they are the only thing that can tell you whether a threshold was wrong.
Alerts and tests
Turn on the webhook in the Alerts card on the dashboard’s Integration tab. It fires a threat.detected event to Slack, Discord or your own endpoint, and it is the difference between learning about the run from your logs and learning about it from support tickets.
Then put the six endpoints in CI. The API accepts deterministic test tokens on any key — test_clean, test_vpn, test_proxy, test_datacenter, test_tor — and the public sk_test_sandbox key needs no account. test_proxy and test_tor return block; test_vpn and test_datacenter return review. So the tests write themselves: post test_proxy to the login and reset endpoints and assert the response is byte-for-byte the wrong-password response; post test_vpn and assert the step-up path; post no token at all and assert the degraded path still returns the normal success and logs a warning. The failure path is the one that matters, and it is the one nobody clicks through by hand.
Checklist
- One
assesshelper, short timeout, fail-open, returnsdecision,newDevice,device,linked,degraded. - Login: lookup, assess, then password; identical failure response; code on
reviewand first-seen devices. - Reset: same sentence for every outcome; token bound to the requesting device; second check on completion; revoke all sessions; notify the old contact.
- MFA changes and recovery codes: existing factor on
review; one-click “this was not me” on every notice. - Contact and payout changes: hold on
review, notify the old address, hold on degraded too. - Session refresh: device recorded at issue, compared hourly, replay forces re-login.
- OAuth callbacks get the login rule; token creation re-authenticates; password reset revokes recent tokens.
- Webhook on, six endpoints in CI with the test tokens.
Questions people ask
- If we can only protect one endpoint first, which one?
- Password reset. It needs no password, it is rarely rate-limited by device, and a completed reset is a takeover with a fresh, legitimate session and the real owner locked out. Login second, then the contact and payout changes where a takeover actually cashes out.
- Do we evaluate on every request once someone is logged in?
- No. Once per session at refresh, roughly hourly, and again at each sensitive action. Evaluating every request adds latency to pages that carry no risk and tells you nothing the refresh check did not.
- What does a first-seen device mean on an established account?
- Not proof of anything; people buy laptops. It is the single most useful number at a login, where it earns an emailed code, and at a payout change it is enough on its own to hold the change for a day and tell the old address.
- Will this block customers who use a VPN?
- Not on its own. A VPN alone returns review, never block, and your code decides what review means at each endpoint. For most products the right answer is a step-up such as an emailed code, which real customers pass and credential lists do not.
Paste it in, then watch the verdicts
The public sk_test_sandbox key returns the documented allow, review and block shapes with no account, so the failure path is testable before you go live. SDKs for Node, Python and PHP, or plain HTTP. The <a href="/api">API reference</a> and the <a href="/pricing">free tier</a> cover the rest.