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

# Returning visitor recognition (trusted-device UX)

> Learn how to recognize a trusted returning device and remove friction: reward a clean, known DeviceID with a lighter, lower-friction path.

Most tutorials here use the Risk Score to raise friction on a suspicious visit. This one runs the same machinery in reverse: a visit that arrives clean and on a device you already trust is a good moment to **remove** friction — skip a redundant check, restore preferences, or smooth the path for someone who has been here before.

## What is returning visitor recognition?

Returning visitor recognition is identifying a device that has visited before and tying it to a known, trusted account, so you can shorten the experience for a genuine return instead of treating every visit as a first-time stranger. It is a convenience layer for personalization and friction removal, not an authentication gate.

ShieldLabs surfaces two inputs for it, both already in every webhook: a durable [DeviceID](/features/identification) and a [Risk Score (0-100)](/features/risk-scoring). The DeviceID is server-derived from the browser environment rather than stored in a cookie, so it recognizes the same device across a cleared cookie, an incognito window, and an IP rotation — which is exactly where a cookie-only "remember me" falls apart. (The VisitorID resets whenever cookies clear, so it is the wrong handle for a return that survives a reset.) The Risk Score tells you the return is arriving on an ordinary, unmasked connection.

<Note>
  This recognizes a **device**, not a person. It is a convenience signal, not a credential — read the guardrails at the end before wiring it to anything sensitive.
</Note>

## Recognize and reward a trusted device

The whole flow is one plain rule:

* **Input:** a clean, low-anonymity session — a Clean-band Risk Score with an empty `signals` array — arriving on a `DeviceID` you already tied to a verified account.
* **Rule:** reward the return. Skip a redundant check, restore the visitor's preferences, or shorten the path.
* **Outcome:** friction removed for a known-good return, with a safe fallback to your normal flow the moment either half of the input is missing.

What makes a return trustworthy is the *absence* of anomaly. The same anonymity signals that flag a masked connection are, by their absence, the positive evidence here: a Clean-band score with an empty `signals` array means no VPN, proxy, Tor, or mismatch fired. That is why you require an **empty `signals` array**, not just a low-ish score — a device on a fresh VPN can present a tidy-looking `public_ip` while its real network IP (`local_ip`) betrays the mask. When the two disagree, `detection_flags.ip_mismatch` is set to `true`, which your gate checks directly (see the code below). A fresh VPN or proxy also fires its own scored signal, lifting the visit out of the Clean band so the empty-`signals` gate catches it anyway.

<Note>
  Match on the **band plus the DeviceID**, never the DeviceID alone. A returning device with a Medium or High score is a returning device on a riskier connection, and the riskier connection is the part that should drive your decision.
</Note>

## Build it

ShieldLabs surfaces the clean session and the DeviceID; your code builds the trust list and decides which friction to remove.

<Steps>
  <Step title="Identify on the page">
    Load the [snippet](/setup/snippet) where the return matters — a returning visitor landing on your app or starting a routine action. Keep the `requestID` to join the browser check to the webhook.
  </Step>

  <Step title="Associate a DeviceID with a trusted account">
    Recognition only works once you have something to recognize. Whenever a visitor completes a real, authenticated action you trust (a login behind your own auth, a verified purchase, an email confirmation), store the DeviceID that arrived on that visit against that account. Over time each account accumulates a small set of devices it has genuinely used.

    ```js Build the trust list on a verified action theme={null}
    // Call this from a flow you already trust: a successful login, a confirmed
    // purchase, an email verification. The DeviceID rides in on the same webhook.
    const ZERO_DEVICE_ID = '00000000-0000-0000-0000-000000000000';

    async function rememberTrustedDevice(accountId, shield) {
      // No real device identity to remember. When the snippet is blocked or
      // JavaScript is disabled the server returns the all-zero DeviceID (and the
      // visit scores 90+), so there is nothing to recognize later.
      if (!shield.device_id || shield.device_id === ZERO_DEVICE_ID) return;

      // Only remember devices seen on a clean, signal-free visit.
      if (shield.score > 9 || shield.signals.length > 0) return;

      await trustedDevices.add({
        accountId,
        deviceId: shield.device_id,
        firstSeen: Date.now(),
      });
    }
    ```
  </Step>

  <Step title="Recognize the return">
    On the next visit, wait briefly for the score, then check the band and the device **together**. A clean score on its own is an ordinary visit; a known DeviceID on its own is not enough either. The recognition is the intersection.

    ```js api/enter.js theme={null}
    app.post('/api/enter', async (req, res) => {
      const { accountId, shieldRequestId } = req.body;

      // Read the scored result from your webhook cache (the shared
      // `waitForScore` helper), falling back to a History API read by request_id.
      const shield = await waitForScore(shieldRequestId, 2000);

      // No score yet is not the same as "trusted". When in doubt, run the full flow.
      if (!shield) return res.json({ recognized: false });

      // No real device identity (snippet blocked or JS disabled returns the
      // all-zero DeviceID): nothing to recognize.
      const ZERO_DEVICE_ID = '00000000-0000-0000-0000-000000000000';
      if (!shield.device_id || shield.device_id === ZERO_DEVICE_ID) {
        return res.json({ recognized: false });
      }

      // The webhook ships the band string, so branch on it. Clean band (0-9)
      // with no anonymity signals is an ordinary, unmasked visit.
      const isClean = shield.risk_score < 10 && shield.signals.length === 0;
      const isKnownDevice = await trustedDevices.has(accountId, shield.device_id);

      if (isClean && isKnownDevice) {
        // Recognized returning device: lighten the experience.
        return res.json({ recognized: true, deviceId: shield.device_id });
      }

      // New device, or a score that is not clean: your normal flow, unchanged.
      return res.json({ recognized: false });
    });
    ```

    `waitForScore` is the shared webhook-cache read (poll the cache, then fall back to a History API read by `request_id`); the [Use Case Tutorials](/use-case) index defines it once, so this tutorial does not repeat it.
  </Step>

  <Step title="Lighten the experience">
    For a recognized return, remove friction. A few safe places to spend it:

    * **Skip a redundant check.** A second-factor prompt the same trusted device already cleared this week can be relaxed, while staying on for anything sensitive.
    * **Restore preferences.** Re-apply the visitor's layout, language, or saved cart before they ask.
    * **Shorten the path.** Pre-fill what you already know, or drop the visitor straight onto the screen they last used.
    * **Soften rate limits.** A recognized device earns more headroom than an anonymous one on the same endpoint.

    Each is a convenience that degrades gracefully: if recognition fails, the visitor simply gets your normal flow.
  </Step>
</Steps>

<Tip>
  The all-or-nothing check above disqualifies any signal at all. If you want some benign masking to still count — a corporate VPN, iCloud Private Relay — branch on the individual [`detection_flags`](/glossary#detection-flags) booleans instead of requiring a fully empty `signals` array (require, say, `!shield.detection_flags.tor && !shield.detection_flags.anti_detect_browser && !shield.detection_flags.ip_mismatch`). Loosen the gate only for convenience decisions, never for anything you would gate on authentication.
</Tip>

## Test it

Confirm recognition holds across the exact resets a cookie-only approach loses to. Visit once on a clean connection, let the visit land against a trusted account, then come back:

* **Clear cookies and storage**, reload, and identify again. The `cookie_id` and `visitor_id` change, but the `device_id` stays the same.
* **Open an incognito or private window** in the same browser and identify. A fresh cookie context still resolves to the same `device_id`.
* **Reconnect on a different network** (Wi-Fi to mobile data). The IP rotates, the `device_id` does not.

Each return should carry a Clean-band score with an empty `signals` array and the same `device_id` you stored on the first visit. That match across cookie, incognito, and IP resets is exactly what a "remember this device" checkbox cannot do on its own.

## Guardrails

<Warning>
  A returning device is a convenience signal, not a credential. State the limits plainly and design around them.

  * **This recognizes a device, not a person.** Anyone using that browser inherits the recognition. A shared family laptop or a borrowed machine will match.
  * **Never store or match the all-zero DeviceID `00000000-0000-0000-0000-000000000000`.** When the snippet is blocked or JavaScript is disabled the server cannot build a device identity and returns this placeholder (the visit also scores 90+). Treat it as unrecognized and run your normal flow.
  * **It is probabilistic, an estimate, not proof of identity.** ShieldLabs reports [Accuracy](/features/accuracy) as up to 99% — high enough to smooth a path, far too low to be the only thing between a stranger and an account.
  * **Never make it the sole gate for anything sensitive.** Money movement, password and email changes, and data exports must always sit behind real authentication. Pair recognition with a login, a second factor, or a re-verification step. Use it to remove a redundant step, never the only step.
  * **Fail closed.** No score, a new device, or a non-clean band all fall back to your full flow. Recognition is the bonus, not the baseline.
</Warning>

## Next

Run the same Score and DeviceID in the other direction with [step-up authentication](/use-case/step-up-authentication), which raises friction when a session is **not** clean, or guard a sensitive return against a hijacked session with [account takeover](/use-case/account-takeover). When the same device starts logging in across more accounts than a person should hold, [account sharing](/use-case/account-sharing) and [multi-accounting](/use-case/multi-accounting) cover the inverse of trust.

For the building blocks underneath this tutorial, the [Identification](/features/identification) reference explains how the DeviceID and VisitorID differ and which one survives what, [Risk Scoring](/features/risk-scoring) covers the score and bands, and the [webhook payload](/api/webhooks) and [History API](/api/server-api) are the two ways to read the `device_id` and `score` for a visit.
