> ## Documentation Index
> Fetch the complete documentation index at: https://docs.shieldlabs.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Credential Stuffing

> Learn how to detect and prevent credential stuffing: throttle logins on a durable DeviceID so rotated IPs cannot reset your rate limits.

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](/features/identification)** 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](/setup/webhooks) / [History API](/api/server-api) | About a second        |
| **Anonymity detection** | "Is this login masked or anonymous right now?"                                 | The `signals` array on the [webhook](/setup/webhooks) / [History API](/api/server-api)     | About a second        |
| **Risk Score**          | "How risky is the visit overall, as one 0-100 number?"                         | `risk_score` on the [webhook](/setup/webhooks) / [History API](/api/server-api)            | About a second        |
| **Patterns**            | "Has this device or local IP already touched many accounts?"                   | [Dashboard Patterns](/features/patterns) + export                                          | Background (\~10 min) |

The [Risk Score (0–100)](/features/risk-scoring) reads the session's anonymity — Datacenter, VPN, proxy, Tor, and anti-detect [signals](/features/anonymity-signals) are common on stuffing traffic, often with the `ip_mismatch` flag set. The dashboard [Patterns](/features/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.

<Warning>
  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.
</Warning>

## 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

<Steps>
  <Step title="Create a ShieldLabs account and get your keys">
    [Sign up for free](https://app.shieldlabs.ai/) 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](/setup/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](/api/server-api), and each webhook endpoint has its own `whsec_…` signing secret. See [Keys](/setup/keys).
  </Step>

  <Step title="Identify every login">
    Wire the [snippet](/setup/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](/use-case) — poll the cache, then fall back to a History API read by `request_id`.
  </Step>

  <Step title="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.

    ```js Throttle by DeviceID across rotated IPs theme={null}
    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);
    });
    ```

    <Note>
      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.
    </Note>

    <Tip>
      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.
    </Tip>
  </Step>

  <Step title="Branch on specific tells with detection_flags">
    When policy depends on a specific condition rather than just the band, read the boolean [`detection_flags`](/glossary#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.

    ```js theme={null}
    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';
    }
    ```

    <Note>
      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.
    </Note>
  </Step>

  <Step title="Watchlist the fan-out with Patterns">
    The defining shape of stuffing is one source touching many accounts. [Patterns](/features/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.

    <AccordionGroup>
      <Accordion title="Many Accounts on One Device" icon="mobile-screen">
        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.
      </Accordion>

      <Accordion title="Many Accounts on One Local IP" icon="network-wired">
        Many accounts reached through the same local IP. Catches a single machine or NAT fanning out across accounts behind a rotating public IP.
      </Accordion>

      <Accordion title="Changing IDs on One Account" icon="arrows-spin">
        The same account whose VisitorID or DeviceID keeps changing, a hint of scripted attempts or anti-detect tooling cycling its environment between tries.
      </Accordion>
    </AccordionGroup>

    Pull the flagged entities from the [dashboard Patterns tab](/features/patterns) (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.

    ```bash How many accounts has this device touched? theme={null}
    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"
    ```

    ```js Escalate a known fan-out device theme={null}
    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' });
    }
    ```

    <Note>
      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.
    </Note>
  </Step>

  <Step title="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.
  </Step>
</Steps>

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

## 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 |

<Card title="Next: Login and 2FA" icon="arrow-right" href="/use-case/step-up-authentication">
  The step-up authentication pattern that pairs with this throttle: when to escalate a risky login to a second factor.
</Card>
