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
_abidcookie — the edge passes it along; from your server, send it assessionToken), 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 —
reportOutcomefrom your server, or theantibot-login: successheader 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.newDeviceandvelocity.newCountryare 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.