Skip to main content
Credential stuffing is the same stolen-password list tried against many accounts, usually from anonymized infrastructure rotating IPs to stay under per-IP limits. ShieldLabs gives you two things an attacker cannot easily rotate away: the anonymity of each login session and the link between the many accounts one device or one local IP touches. Your code uses both to add friction on top of your own login rate limits.

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-scoped visitor_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:
LayerWhat it answersWhere you read itLatency
Identification”Is this the same device, even after cleared cookies, incognito, or a new IP?”The durable device_id on the webhook / History APIAbout a second
Anonymity detection”Is this login masked or anonymous right now?”The signals array on the webhook / History APIAbout a second
Risk Score”How risky is the visit overall, as one 0-100 number?”risk_score on the webhook / History APIAbout a second
Patterns”Has this device or local IP already touched many accounts?”Dashboard Patterns + exportBackground (~10 min)
The Risk Score (0–100) reads the session’s anonymity — Datacenter, VPN, proxy, Tor, and anti-detect signals are common on stuffing traffic, often with the 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.
Bring your own failed-attempt counters and login rate limits. ShieldLabs supplies what they cannot: how anonymous each session is, and which accounts a single device or local IP has touched. Combine the two, and keep the throttling logic in your backend.

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

1

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.
2

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.
3

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
app.post('/api/login', async (req, res) => {
  const { username, password, shieldRequestId } = req.body;

  const shield   = await waitForScore(shieldRequestId, 1500);
  const score    = shield?.score ?? 0;          // 0–100
  const deviceId = shield?.device_id;

  // Your own counter, keyed on the durable DeviceID (survives IP rotation).
  const attempts = await bumpAttemptCount(`dev:${deviceId}`); // 15 min window

  // 1. One device hammering many logins, even across rotated IPs and accounts.
  if (deviceId && attempts > YOUR_DEVICE_ATTEMPT_LIMIT) {
    return res.status(429).json({ action: 'rate_limited' });
  }

  // 2. A raised score is common on stuffing traffic and raises the cost of a try.
  if (score >= 60) {
    return res.status(200).json({ action: 'step_up_2fa' });     // High band
  }
  if (score >= 30) {
    return res.status(200).json({ action: 'require_captcha' });  // Medium band
  }

  return continueLogin(username, password, res);
});
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.
Combine keys for defense in depth: throttle on the DeviceID (survives IP rotation), on the local IP, and on the account being targeted — a stuffing run trips at least one even when it rotates the others. The local IP is the local_ip.ip field on the webhook (the real network address), distinct from public_ip.ip (the public IP an attacker rotates through proxy pools). The machine behind a NAT keeps reaching you on the same local_ip.ip even as the public one cycles. The dashboard calls this same value the Local IP.
4

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.
function loginFriction(shield) {
  const score = shield?.score ?? 0;     // 0–100
  if (score >= 60) return 'strong';     // High band: CAPTCHA + 2FA
  if (score >= 30) return 'medium';     // Medium band: CAPTCHA
  return 'none';
}
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.
5

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.
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 reached through the same local IP. Catches a single machine or NAT fanning out across accounts behind a rotating public IP.
The same account whose VisitorID or DeviceID keeps changing, a hint of scripted attempts or anti-detect tooling cycling its environment between tries.
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.
How many accounts has this device touched?
curl "https://account.shieldlabs.ai/api/v1/history/device_id/5eb7fd5c-1c5e-4a9f-9b21-7d2e8c0a1234?limit=100" \
  -H "Authorization: Bearer sec_your_private_api_key"
Escalate a known fan-out device
async function deviceFanOut(deviceId) {
  const rows = await shieldHistory('device_id', deviceId, 100);
  return new Set(rows.map((r) => r.user_hid).filter(Boolean)).size; // distinct accounts
}

if ((await deviceFanOut(deviceId)) >= YOUR_ACCOUNT_FANOUT_LIMIT) {
  return res.status(200).json({ action: 'step_up_2fa' });
}
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.
6

Tune to your product

Start in logging-only mode, watch where your real logins land across the bands and patterns, then turn on friction for the highest-risk combinations first.

Test it

You do not need an attack to confirm the throttle works. Open your login page, complete an identification, and note the device_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. A guide, not a rule. Layer the conditions: friction should rise as more of them stack.
ConditionSuggested login action
Clean session (Score under 30), normal attempt countAllow
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.