What is credential stuffing?
Credential stuffing is an attack that replays username and password pairs leaked from one breach against the login forms of unrelated services, betting on password reuse. The attempts fan out across many accounts from anonymized, IP-rotating infrastructure so no single account or address crosses a per-account or per-IP limit.How ShieldLabs surfaces it
The keys an attacker rotates cheaply — the public IP and the cookie-scopedvisitor_id — reset every attempt. The server-derived DeviceID does not: it is derived from hundreds of stable browser components rather than stored, so it survives cleared cookies, incognito, and IP rotation, and a throttle keyed on it keeps counting across all three. Four layers add friction on top of your own counters:
| Layer | What it answers | Where you read it | Latency |
|---|---|---|---|
| Identification | ”Is this the same device, even after cleared cookies, incognito, or a new IP?” | The durable device_id on the webhook / History API | About a second |
| Anonymity detection | ”Is this login masked or anonymous right now?” | The signals array on the webhook / History API | About a second |
| Risk Score | ”How risky is the visit overall, as one 0-100 number?” | risk_score on the webhook / History API | About a second |
| Patterns | ”Has this device or local IP already touched many accounts?” | Dashboard Patterns + export | Background (~10 min) |
ip_mismatch flag set. The dashboard Patterns link one source to many accounts over time. The point is not a single verdict but raising the cost of each attempt until the attack is no longer worth running.
Slow down credential stuffing
Key your failed-attempt counter on the durable DeviceID, not just the IP, and read the per-session Risk Score on top. The rule your code applies: one device past your attempt limit gets rate-limited even across rotated IPs, a Medium-band score adds a CAPTCHA, and a High-band score adds a second factor. The outcome is that each attempt costs the attacker more until the run is no longer worth completing. A throttle keyed only on the IP, a cookie, or a session fails here — attackers rotate IPs cheaply, clear cookies, and run incognito, so every one of those keys resets and the count never builds. Keying the throttle on the DeviceID is what makes that rotation stop working.Build it
Create a ShieldLabs account and get your keys
Sign up for free and get 5,000 identifications, or log in if you already have an account. Register the domain you want to identify visitors on, then open the Keys page. Use the Public Key to initialize the snippet in the browser, and keep your server-side credentials on your backend: the Private API Key authenticates the History API, and each webhook endpoint has its own
whsec_… signing secret. See Keys.Identify every login
Wire the snippet into your login step: call
checkAuthenticatedUser (or checkAnonymous before the account is known). The scored result arrives on the webhook and you read it server-side with the shared waitForScore helper — poll the cache, then fall back to a History API read by request_id.Throttle on the DeviceID, add friction on anonymity
Key your failed-attempt counter on the DeviceID so rotating IPs no longer resets the limit, then layer score-band friction on top. The Score already rolls the datacenter, VPN, proxy, Tor, and anti-detect signals into one number, so branch on the band — not on individual labels, which can change.
Throttle by DeviceID across rotated IPs
A blocked or JavaScript-disabled browser can return an all-zero DeviceID (
00000000-0000-0000-0000-000000000000), since the components needed to derive a stable id were never collected. Treat that as “no DeviceID”: skip the device key for it and fall back to the IP and account context, so many distinct attempts do not collapse onto one zero key.Branch on specific tells with detection_flags
When policy depends on a specific condition rather than just the band, read the boolean
detection_flags on the webhook: datacenter_ip, abuser, tor, anti_detect_browser, ip_mismatch, and more. These are stable booleans built for branching, so a rule like “datacenter plus abuser flag, harder challenge” reads cleanly. A masked login can also show detection_flags.ip_mismatch: true — the public public_ip and the real network IP (local_ip) resolve to different countries. Treat it as corroborating evidence, not a trigger on its own: it is informational, does not change the Risk Score, and can be benign (mobile networks often route over different paths). Use the signals array ({ name, weight }) only for the explainable breakdown.Honest framing: a legitimate user on a corporate VPN logs in every day. Anonymity raises friction (a CAPTCHA, a second factor), it does not justify a hard block on its own. Reserve outright rejection for the combination of anonymity, a high failed-attempt count, and a device or IP that is already fanning out across accounts.
Watchlist the fan-out with Patterns
The defining shape of stuffing is one source touching many accounts. Patterns link sessions over time and grade each entity Suspicious then Dangerous as that count climbs. Below the Suspicious threshold an entity is the unflagged baseline, which is never recorded.
Pull the flagged entities from the dashboard Patterns tab (CSV or JSON) and feed the Dangerous DeviceIDs and local IPs into your throttle as a watchlist. You can also reconstruct a device’s fan-out live from the History API.
Many Accounts on One Device
Many Accounts on One Device
One device attempting or reaching many different accounts. Keyed on the durable DeviceID, so it holds even as the attacker rotates IPs and clears cookies between attempts.
Many Accounts on One Local IP
Many Accounts on One Local IP
Many accounts reached through the same local IP. Catches a single machine or NAT fanning out across accounts behind a rotating public IP.
Changing IDs on One Account
Changing IDs on One Account
The same account whose VisitorID or DeviceID keeps changing, a hint of scripted attempts or anti-detect tooling cycling its environment between tries.
How many accounts has this device touched?
Escalate a known fan-out device
History reads on
account.shieldlabs.ai, the webhook stream, and the dashboard export are free; the alternate api.shieldlabs.ai history path bills 1 request per returned row. Lean on the free sources for the bulk of the work and use the live fan-out read for a device you are about to act on.Test it
You do not need an attack to confirm the throttle works. Open your login page, complete an identification, and note thedevice_id on the webhook. Now repeat in the ways that should not reset it: a fresh incognito window, the same browser after clearing cookies and storage, and (where you can) a second public IP. Because the DeviceID is server-derived rather than stored, the same device_id comes back each time, so your per-device counter keeps climbing across all of those attempts instead of starting over. Switch to a genuinely different physical device or browser environment and the device_id changes, confirming the key is tied to the device and not to anything an attacker can clear.
Recommended starting policy
A guide, not a rule. Layer the conditions: friction should rise as more of them stack.| Condition | Suggested login action |
|---|---|
| Clean session (Score under 30), normal attempt count | Allow |
| Score in the Medium band (30 to 59) | Require a CAPTCHA |
| Score in the High band (60+) | Require a second factor |
| DeviceID over your failed-attempt limit (across rotated IPs) | Rate-limit (HTTP 429) |
| Device or local IP flagged “Many Accounts on One…” | Step up to 2FA or block the device, then review |
Next: Login and 2FA
The step-up authentication pattern that pairs with this throttle: when to escalate a risky login to a second factor.