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

# Account Sharing

> Learn how to detect and prevent account sharing by seeing how many devices and countries each account is used from, and enforce your sharing policy.

Account sharing has a recognizable shape on the wire: one account, many devices, sometimes many countries in a short window. ShieldLabs gives you five layers to see it, including the **Account sharing** High-Risk Event, and you choose the action for each case (a paid seat is fine, a credential resold to fifty people is not).

## What is account sharing?

Account sharing is when one set of login credentials is used across more people or devices than a plan allows: a password handed to friends, a single seat split across a team, or a subscription resold to many strangers. It shows up as one account appearing on more distinct devices and locations than a single user could plausibly produce.

## How ShieldLabs surfaces it

ShieldLabs ties each identification of a signed-in user to the account and to the device behind it, and shows how far the account has spread. Five layers answer five different questions:

| Layer | What it answers | Where you read it | Latency |
| - | - | - | - |
| **Account** | "How many devices and countries has this account used?" | The [History API](/api/server-api) by `user_hid`: the account's devices, countries and worst band | On demand |
| **Identification** | "Is this the same device, and is it a new device or country for this account?" | The durable `device_id` on the [webhook](/setup/webhooks) / [History API](/api/server-api) | About 300 ms |
| **Risk signals** | "Is this session masked or automated right now?" | The `signals` array on the [webhook](/setup/webhooks) / `score_details` on the [History API](/api/server-api) | About 300 ms |
| **Risk Score** | "How risky is this identification overall, as one 0-100 number?" | `risk_score` on the [webhook](/setup/webhooks) / `score` on the [History API](/api/server-api) | About 300 ms |
| **High-Risk Events** | "Is this account used from many devices, or from places one person could not reach?" | [Account sharing](/features/high-risk-events#account-sharing) and [Impossible travel](/features/high-risk-events#impossible-travel) on the user, in the analytics dashboard, the API and webhooks | When detected |

The anchor for the rest is the durable **Device ID**, derived on the server, so a sharer cannot reset it by clearing cookies, opening an incognito window or switching networks. Counting an account's devices by cookie or IP undercounts badly, because each of those reads as a fresh device; the Device ID holds steady, so a credential reused on the same device still resolves to one device instead of inflating the count.

<Note>
  Account sharing is a policy question. A family plan, a shared team login and a resold credential can all show as one account on many devices. ShieldLabs detects the sharing and shows the account's devices; you choose the action for each case under your terms of service.
</Note>

## Prevent account sharing

When the **Account sharing** High-Risk Event arrives for a user through the API or webhooks, or when you review it in the analytics dashboard, act on the account: mark it in your own system, so its next sessions step up verification, notify the account owner, or restrict the extra sessions instead of letting one credential run everywhere at once. For a live check against your plan's seat limit, count the distinct devices and countries per account, as the steps below show. Weigh a masked session more heavily: when a sharer uses a VPN to look local, `local_ip`, the address the browser itself reports, can show a different country from the public IP. ShieldLabs detects the spread and scores each identification with the [Risk Score (0-100)](/features/risk-scoring); you choose the action for each case in your backend. The steps below wire it up.

## Build it

<Steps>
  <Step title="Create a ShieldLabs account and get your keys">
    [Start Free](https://app.shieldlabs.ai/) with 5,000 identifications, one time, no credit card, or log in. In the analytics dashboard, add the domain you want to protect under **Integration > Domains**, then open **Integration > API keys** and copy its keys with the copy button next to each. The **Public Key** loads the snippet in the browser. Keep the server credentials on your backend: the **Private API Key** reads the [History API](/api/server-api), and each webhook endpoint has its own `whsec_…` signing secret. See [API keys](/setup/keys) and [Integration](/dashboard/integration).
  </Step>

  <Step title="Identify authenticated sessions">
    Add the [snippet](/setup/snippet) to your app and pass the account's hashed User HID with `checkAuthenticatedUser` on every signed-in page. Account sharing, Impossible travel and every account-level read below are built on it. When a session starts (right after login), call `forceCheckAuthenticatedUser` instead, so the new session always gets its own identification: a plain check is skipped when the same user was checked in this visit within the last five minutes. Pass a hash, never a raw email or user id.

    ```html app.html theme={null}
    <script type="module">
      const mod = await import(
        'https://cdn.shieldlabs.ai/snippet.js?publicKey=YOUR_PUBLIC_KEY'
      );
      // Right after login: a fresh identification for the new session.
      // On other signed-in pages, checkAuthenticatedUser with the same hashed id.
      mod.forceCheckAuthenticatedUser('8a9f-hashed-account-id', {
        onInitialized: (result) => {
          fetch('/api/session-check', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ shieldlabsRequestId: result.requestID ?? '' }),
          });
        },
      });
    </script>
    ```
  </Step>

  <Step title="Check the device on every session">
    Read the scored result for that request ID with the shared [`waitForScore` helper](/use-case#the-shared-helpers) (your webhook cache, falling back to the [History API](/api/server-api)), then compare the Device ID against the account's known devices.

    ```js api/session-check.js theme={null}
    app.post('/api/session-check', async (req, res) => {
      const { shieldlabsRequestId } = req.body;
      const userHid = req.user.hashedId; // the hashed id you pass to the snippet

      // Pull the ShieldLabs result for this session: the webhook `data` object
      // (the shared helper maps a History fallback to the same field names).
      const risk = await waitForScore(shieldlabsRequestId, 2000);
      if (!risk || risk.user_hid !== userHid || risk.risk_score > 100) {
        // No identification, someone else's, or the 999 rate-limit marker.
        return res.json({ action: 'review', reason: 'unverified_session' });
      }
      const deviceId = risk.device_id;
      const country  = risk.public_ip?.country;

      // An all-zero Device ID means no usable device signals reached ShieldLabs:
      // "device unknown", not a new device. Route it to review.
      if (deviceId === NIL_DEVICE) {
        return res.json({ action: 'review', reason: 'device_unknown' });
      }

      // Compare against what you already know about this account.
      const known = await knownDevicesFor(userHid);   // your own store

      if (!known.devices.has(deviceId)) {
        // A device this account has never used. Choose the action: re-auth, notify, or log.
        if (known.devices.size >= YOUR_DEVICE_LIMIT) {
          return res.json({ action: 'reauth_required', reason: 'new_device_over_limit' });
        }
        await rememberDevice(userHid, deviceId, country);
        return res.json({ action: 'notify_new_device' });
      }

      return res.json({ action: 'allow' });
    });
    ```

    **Read the country with care behind a VPN.** `public_ip.country` comes from the public IP, which a VPN exit can put anywhere. `local_ip.country` belongs to the Local IP, the address the browser itself reports, which can differ from the public IP behind a VPN or proxy; `local_ip.ip` is empty when the Local IP was not captured. When the two addresses differ, `detection_flags.ip_mismatch` is `true`. The flag is informational, adds nothing to the Risk Score and can be ordinary on mobile networks, so compare `local_ip.country` with `public_ip.country` rather than branching on the flag alone. A credential whose `public_ip.country` roams while `local_ip.country` stays fixed is more likely one masked location than real spread, so read the two together before you count an account's countries.

    <Warning>
      One person using two browsers (Chrome then Safari) shows up as two devices, so "many devices" can include a single person's own browsers, a nuance the [identifiers reference](/features/identification) explains. Weigh it with the country spread, the IP, and your own context before you treat it as sharing.
    </Warning>
  </Step>

  <Step title="See the spread over time">
    Identification catches a new device right now. The harder signal is an account that quietly spreads across many devices, or appears in places one person could not reach in the time between them. ShieldLabs detects these on your users as [High-Risk Events](/features/high-risk-events), each at **Medium** or **High** confidence, and they are available in the analytics dashboard, the API and webhooks. Events are a separate axis from the Risk Score and are keyed on the User HID you pass with `checkAuthenticatedUser`.

    <Frame caption="An account with an Account sharing event in the analytics dashboard, with the devices linked to it.">
      <img className="block dark:hidden" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/event-account-sharing.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=bdc44c261a010798a890fb810a69f3ed" alt="The user card for User HID c47a1e90b3d25f18 in the analytics dashboard: the Trusted band pill, a red Account sharing pill (High confidence), 14 identifications, all Trusted, and Linked devices open with 5 devices, each Trusted." width="2254" height="1360" data-path="images/dashboard/event-account-sharing.png" />

      <img className="hidden dark:block" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/event-account-sharing-dark.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=b17456cfa4ebb2b0c5236596315246db" alt="The user card for User HID c47a1e90b3d25f18 in the analytics dashboard in the dark theme: the Trusted band pill, a red Account sharing pill (High confidence), 14 identifications, all Trusted, and Linked devices open with 5 devices, each Trusted." width="2254" height="1360" data-path="images/dashboard/event-account-sharing-dark.png" />
    </Frame>

    <AccordionGroup>
      <Accordion title="Account sharing" icon="users">
        One account used from several distinct devices. By default it fires from 4 devices on one account, and the threshold is configurable. The core account-sharing and account-resale shape, keyed on the User HID.
      </Accordion>

      <Accordion title="Impossible travel" icon="earth-americas">
        The same account active in locations it could not reach in the time between them. Catches a credential shared across regions; read it with the device spread.
      </Accordion>

      <Accordion title="Account takeover" icon="location-dot">
        An existing account appearing in a new environment that points to someone else using it. A takeover or hand-off signal, distinct from steady sharing.
      </Accordion>
    </AccordionGroup>

    To reconstruct the device and country counts live, read the account's identifications from the [History API](/api/server-api) with the shared `accountView` helper:

    ```js Count devices and countries per account theme={null}
    const account = await accountView(userHid);

    // The device count is the durable signal. The country count is read from
    // the public IP, which a VPN can put anywhere, so treat it as the softer signal.
    // For a single identification, detection_flags.ip_mismatch marks a public IP
    // that differs from local_ip. It is informational: weigh it, do not gate on it.
    if (account.devices.size >= YOUR_DEVICE_LIMIT || account.countries.size >= YOUR_COUNTRY_LIMIT) {
      flagForReview(userHid, {
        devices: account.devices.size,
        countries: account.countries.size,
      });
    }
    ```

    <Note>
      History reads on `account.shieldlabs.ai` and webhook delivery are free. High-Risk Events are available in the analytics dashboard, the API and webhooks. When an Account sharing event arrives for a user through the API or webhooks, or when you review it in the analytics dashboard, add the User HID, with the devices and local IPs linked to it, to a watchlist in your datastore; at the session check, check the incoming `user_hid` and `device_id` against it. The History API returns every device an account has used, by `user_hid`.
    </Note>
  </Step>

  <Step title="Tune to your product">
    A streaming service tolerates more devices than a single-seat B2B tool. Start in logging-only mode, watch how your real accounts distribute, then set the device and country limits in your own session check to match your terms.
  </Step>
</Steps>

## Test it

Confirm the Device ID holds before you wire policy to it. Log in to one test account, then revisit on the same machine and browser in an incognito window and after clearing cookies: the `cookie_id` and `visitor_id` change each time, but the `device_id` stays the same, so one browser does not look like three devices. Then log the same account in from a second browser or a second device: a new `device_id` appears. That is the new device your check counts, and the reason your device limit should allow for one person's own browsers.

## Recommended starting policy

A guide, not a rule. The right device and country limits depend entirely on your product.

| Signal | Suggested action |
| - | - |
| Known device, known country | Allow |
| New device, within your device limit | Notify the account owner, remember the device |
| New device, over your device limit | Require re-authentication on the new device |
| No identification for the session, or an all-zero Device ID | Review before you count the device |
| Account with an **Account sharing** event, **Medium** confidence | Notify the account owner, review against your sharing policy |
| Account with an **Account sharing** event, **High** confidence | Step up verification and restrict the extra sessions |
| Account with an **Impossible travel** event (Medium or High confidence) | Step up verification, review against your sharing policy |
| Account with an **Account takeover** event (Medium or High confidence) | Treat as possible takeover: force re-auth with the [Login and 2FA](/use-case/step-up-authentication) step-up guide |

A sudden device-and-country jump on an existing account can be sharing, but it can also be [account takeover](/use-case/account-takeover) or the tail of a [credential-stuffing run](/use-case/credential-stuffing). The same Device ID and webhook payload feed all three, so once you have this wired you can branch on intent.

<Card title="Next: Acting on results" icon="arrow-right" href="/guides/acting-on-risk-score">
  The full decision playbook, including how to combine the account's spread with the Risk Score of each identification and its signals.
</Card>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.