← All docs

Protecting sign-in

Credential stuffing is a list of stolen email–password pairs worked against your sign-in form, one pair per request, from as many addresses as the attacker can rent. Account takeover is the pair that worked. This page is the three ways to stop it here, from least code to most control, and how they combine.

Code on your site Sees Best for
At the edge none every sign-in post, its account, its outcome most sites
From your server a few lines in the sign-in handler the same, plus anything your handler knows APIs, apps, no CDN in front
With the agent the agent on the sign-in page the browser from the inside the strongest signal on the people who do run it

What is being watched

Whichever way you choose, every sign-in attempt is judged on:

  • The client. A script that names itself (python-requests, curl), one wearing a browser's name without a browser's headers, a crawler's name from someone else's address, an automated browser. See assessing a request.
  • One address, many accounts. A person signs into one or two accounts; a stuffing run works down a list. Five accounts from one address in ten minutes is a reason (velocity:accountsFromAddress, 40); fifteen is a challenge on its own (60). An IPv6 source is counted across its /48 too, at sixteen times those (velocity:accountsFromNetwork): a tunnel broker hands anyone a /48, sixty-five thousand /64s, and a list worked down from a fresh /64 at every attempt is one account an address. A /48 the agent ran from on twenty devices in the last hour is a crowd's — a campus's, a carrier's — and not counted so.
  • Failures. A stuffing run fails on nearly every pair it tries. Ten failed sign-ins from an address with almost no successes beside them weigh heavily (velocity:failedSignIns, 40; thirty, 60) — and from a device, by its session token, whatever addresses it uses, which is what a browser behind a proxy pool or on a VPN's exit leaves; an office that fails often but succeeds far more says nothing. Ten failures on one account, from wherever they came, add 20 (velocity:failedForAccount).
  • One account, many addresses. A spray or a takeover rotating proxies. It weighs lighter, because the account's owner is among those addresses (velocity:addressesForAccount: 25 from five, 45 from fifteen).
  • One device, many accounts. When the attempt carries the visitor's receipt (the _abid cookie — the edge passes it along; from your server, send it as sessionToken), the device is known by its visitor id. One browser working down a list behind a proxy pool shows one account per address, and all of them from itself: it is held to the same thresholds as an address (velocity:accountsFromDevice). Many devices on one account is a spray rotating browser profiles and addresses together, weighed at its own marks (velocity:devicesForAccount) — a reason from five devices, more from ten (devices are counted per address, so profiles rotated behind one address count once).
  • A device, or a country, the account never signed in from. Once your application reports outcomes — reportOutcome from your server, or the antibot-login: success header at the edge — every successful sign-in is remembered for the account, the device it came from and the country, for 180 days, by digest. (A success the edge only read from a status code is counted but never remembered: a wrong password answered 200 must not write an attacker's device into the history.) A takeover with a stolen password signs in from neither — and one that leaves the cookie behind, carrying no receipt, is from no device the account knows either. That scores nothing on its own (a new phone, a holiday); velocity.newDevice and velocity.newCountry are there for your rule — the editor's Step up sign-ins from a new device in a new country asks for a second factor when both are true.
  • What other sites saw from the address in the last two days — network reputation.
  • What the email address says, when the account is one: a throwaway-inbox service, a local part shaped like a generated one, and — the shape of accounts made in bulk — one inbox under several names: jane+1@, jane+2@, [email protected], all delivered to Jane. See what an email address says.

The account is digested on arrival: no username and no address is stored for any of this.

The same call protects sign-up: name the sign-up path in ANTIBOT_LOGIN_FIELDS (or send account from your handler) and the address is read the same way. Fake accounts are made from addresses that cost nothing, so that is where the email signals matter most. Outcomes are read the same way too: a sign-up refused with a 422 counts as a failure, and many of those from one address is someone finding out which emails already have an account.

1. At the edge, no code

With the edge enforcer in front of the site, name the sign-in endpoint and the field that carries the account:

"ANTIBOT_ASSESS_SECRET_KEY": "sk_live_…",
"ANTIBOT_LOGIN_FIELDS": "/login=email,/api/session=user.username"

Every POST to those paths is assessed with its account — even one carrying a valid receipt, since a browser that passed the agent once can still work down a list. The edge reads that one field from a copy of the body (form, JSON or multipart, up to 32 KB); your handler receives the body untouched, and the password goes nowhere.

  • block — a 403 at the edge.
  • challenge — forwarded to your handler with antibot-assess-action: challenge (and -score, -reasons, -id). A 403 to a form post would show a person behind an office NAT a blank error; your handler can ask for a CAPTCHA or a second factor instead. Forged copies of these headers are stripped from every request, so trust them only from the edge.
  • allow — forwarded as usual.

The edge learns the outcome from your response: send antibot-login: success or antibot-login: failure (it is removed before the visitor sees it), or let the status code say it — 401, 403, 422, 429 are failures, 2xx and 3xx successes. On CloudFront the edge never sees your answer, and on Akamai the verdict cannot reach your handler; report outcomes from your server there.

2. From your server

verdict = antibot.assess(
    ip=client_ip,
    headers=raw_header_pairs,          # in arrival order where your framework keeps it
    method="POST",
    url="https://shop.example/login",
    account=form["email"],
    session_token=request.cookies.get("_abid"),  # the device, where the agent ran
)
if verdict["action"] == "block":
    abort(403)
if verdict["action"] == "challenge":
    require_captcha_or_second_factor()

ok = check_password(form["email"], form["password"])
antibot.report_outcome(
    ip=client_ip,
    account=form["email"],
    outcome="success" if ok else "failure",
    session_token=request.cookies.get("_abid", ""),  # whose attempt it was: "" for none
)

The same in every server SDK: assess and reportOutcome. Send the outcome after every attempt: the failures are what turn a slow, spread-out stuffing run from "a few odd requests" into a block.

3. With the agent

Put the agent on the sign-in page and identify on submit with the account as linkedId. The identification sees the browser from the inside — automation, tampering, a stealth layer, a replayed payload — and returns a receipt. On the server, check the receipt offline (verifySessionToken, bound to the browser's User-Agent, and its environment) and the decision your rules reached. This is the strongest signal there is on the clients that run the agent; stuffing tools mostly do not, which is why the first two ways exist.

Your own policy

Rules that apply to requests without the agent run on every sign-in assessment. The editor's Stop credential stuffing template is a start — a challenge, at the marks above, so the people behind an office's address pass while the script working its list does not. Whoever passed is not asked again for the address's counts, nor is anyone on a crowd's address (ipInfo.crowd: a VPN's exit, a carrier's), where they are everyone's; the device's own list and failures hold it from any address. On an address that is no crowd's, the scoring itself keeps counting the address for whoever passed, so a list worked from one address is still challenged or refused:

path == "/login" and ((not ipInfo.crowd and not session.challenged and ((velocity.failuresFromAddress >= 10 and velocity.successesFromAddress <= 1) or velocity.accountsFromAddress >= 15)) or (velocity.failuresFromDevice >= 10 and velocity.successesFromDevice <= 1) or velocity.accountsFromDevice >= 15)

Challenge throwaway sign-ups does the same for account creation, once — the inbox is the same on the retry:

path contains "signup" and not session.challenged and (email.disposable or velocity.variantsOfAccount >= 3)

An allow rule placed first lets a known integration through whatever the score.

Knowing it is happening

Edge traffic in the dashboard shows the verdicts per hour and why. When most of what arrives is refused where an hour before little was, an attack alert opens: a webhook to the endpoints that asked, an email to owners and admins, a banner on the page.