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

# Paywall Bypass

> Meter free articles on a Device ID that holds through cleared cookies and private windows.

A metered paywall lets a reader see a few free articles, then asks them to subscribe. The classic dodge is to reset the count: clear cookies, open an incognito window, and the meter starts over from zero. That works because most meters key off a first-party cookie, and a cookie is the one thing the reader controls. This tutorial meters on the Device ID instead, an identifier the reader cannot reset that way, and on the account for signed-in readers.

## What is paywall bypass?

Paywall bypass is when a reader gets past a metered or subscription wall without paying, most often by resetting the free-view count. They clear cookies, open a private window or rotate their IP so the meter forgets them and starts over. Each reset mints a fresh cookie and therefore a fresh Visitor ID (one device plus one cookie), which is exactly why a cookie-keyed meter forgets the reader.

## How ShieldLabs surfaces it

Every metered view is one identification, and each identification carries a Device ID that holds through those resets, so you count views per device on an identifier the reader cannot wipe. The request ID the snippet hands your page is the join key your backend uses to look up that view's Device ID before it serves or walls the article. For a signed-in reader, the same identification also carries the hashed User HID you pass, so you can count per account as well.

A cookie meter and a Device ID meter behave identically until the reader tries to game them. Then they diverge:

| Reader action | Cookie or Visitor ID meter | Device ID meter |
| - | - | - |
| Reads another article | Count goes up | Count goes up |
| Clears cookies | Count resets to 0 | Same Device ID, count holds |
| Opens an incognito window | Count resets to 0 | Same Device ID, count holds |
| Switches networks or uses a VPN | Count holds (an IP-keyed meter resets) | Same Device ID, count holds |
| Switches to a different browser | New count | New Device ID, new count (see limits below) |

The cookie is minted in the browser and lost the moment cookies are cleared, and the Visitor ID is one device plus one cookie, so it resets too. The Device ID holds, and metering on it closes the reset loophole. An IP is no steadier: a VPN, proxy or mobile carrier hands the same reader a fresh address on demand. ShieldLabs holds the count to the device, and you choose where the wall sits.

<Note>
  This is a metering policy. A reader who hits the wall after their free views has read what the plan allows. The Device ID keeps the count honest through cookie resets, and you set the limit and what the wall says.
</Note>

## Stop paywall bypass

Read the Device ID off the webhook for every article view. The rule to apply: increment a per-device counter, compare it against your free limit, and show the wall once a device passes the limit; for a signed-in reader, count per account too. The outcome is a meter the reader cannot reset by clearing cookies, opening incognito or rotating their IP, because all three keep the same Device ID.

## 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 on every article view">
    Load the [snippet](/setup/snippet) on every metered article page and call `forceCheckAnonymous` on each view. Within one visit (while a page of your site stays open in the browser, across route changes in a single-page app and across open tabs), `checkAnonymous` and `checkAuthenticatedUser` run at most one identification every five minutes for the same user. A call inside that window posts nothing, counts nothing, and its `onInitialized` handler receives `{ status: "not_initialized" }`. On a multi-page site, a full page load in the only open tab can start a new visit with its own identification. A reader who keeps another tab of your site open, or moves between articles in a single-page app, stays in one visit, so a plain call on the next article would reach your backend with no request ID. `forceCheckAnonymous` runs an identification every time, keeps the current Session ID and restarts the five-minute window. Each metered view is one identification against your included identifications. Identification requests are rate-limited per visitor IP and per domain (see [Rate limits](/rate-limits)). Readers behind one office or campus IP share the per-IP limit; once it is reached, identifications from that IP carry the 999 marker until the ban clears, and the handler below meters those views by cookie rather than by the shared IP.

    ```html article.html theme={null}
    <!-- Every metered article page -->
    <script type="module">
      const mod = await import(
        'https://cdn.shieldlabs.ai/snippet.js?publicKey=YOUR_PUBLIC_KEY'
      );
      const articleId = document.body.dataset.articleId;
      // One identification per metered view, even when the reader opened another
      // article a minute ago. The callback gives you the requestID; the Device ID
      // and the Risk Score arrive on your backend by webhook.
      mod.forceCheckAnonymous({
        onInitialized: async (result) => {
          const res = await fetch('/api/meter', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              articleId,
              shieldlabsRequestId: result.status === 'initialized' ? result.requestID : null,
            }),
          });
          const { action } = await res.json();
          if (action === 'paywall') showPaywall(); // your wall
        },
      });
    </script>
    ```

    On signed-in pages, call `mod.forceCheckAuthenticatedUser(hashedAccountId, { onInitialized })` with the same callback instead, passing a **hashed or pseudonymous** account id, never a raw email.
  </Step>

  <Step title="Count per Device ID and wall at your free limit">
    Your backend reads the identification for that request ID from the shared [`waitForScore` helper](/use-case), then bumps the counter for the Device ID it carries and compares it against your free limit. Because clearing cookies and opening incognito both keep the same Device ID, the reader cannot zero the count by resetting browser storage.

    ```js api/meter.js theme={null}
    const FREE_LIMIT = 5; // your free articles per device per period

    app.post('/api/meter', async (req, res) => {
      const { articleId, shieldlabsRequestId } = req.body;
      const userHid = req.user?.hashedId; // signed-in readers: the hashed id you pass to the snippet

      // The identification for this view: webhook `data`, or a History row mapped
      // to the same field names, or null when the view carries no request ID.
      const risk = await waitForScore(shieldlabsRequestId, 2000);

      // No identification, another reader's identification or no usable Device ID:
      // meter this view by cookie or IP (next step).
      if (!risk || (userHid && risk.user_hid !== userHid)) {
        return res.json(meterByCookieOrIp(req, articleId));
      }
      // The 999 rate-limit marker. Readers behind one office or campus IP share the
      // per-IP limit, so meter these views by cookie, never by that shared IP.
      if (risk.risk_score > 100) {
        return res.json(meterByCookie(req, articleId));
      }
      if (!risk.device_id || risk.device_id === NIL_DEVICE) {
        return res.json(meterByCookieOrIp(req, articleId));
      }

      // Per-device counter in your own store.
      const views = await incrementDeviceViews(risk.device_id, articleId);
      if (views > FREE_LIMIT) {
        return res.json({ action: 'paywall', reason: 'free_limit_reached', views });
      }
      return res.json({ action: 'allow', reason: 'within_free_limit', views });
    });
    ```

    Metering reads the Device ID straight off the webhook, which never counts against your included identifications; each `forceCheckAnonymous` call is one identification. If a webhook is dropped (delivery is at-most-once, no retries), `waitForScore` falls back to a [History API](/api/server-api) read by `request_id`, so a dropped webhook does not leak a free view. `NIL_DEVICE` is the all-zero Device ID, exported by the [shared helpers](/use-case).

    In the analytics dashboard, a [device card](/dashboard/entity-card) lists its **Linked visitors**: one Device ID with a Visitor ID for every cookie reset, which is the reset loophole the device meter closes.

    <Frame caption="One device and the Visitor IDs its cookie resets created, in the analytics dashboard.">
      <img className="block dark:hidden" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/device-card-linked-visitors.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=97587c61c9de073f03511f13382e4a8b" alt="The device card for Device ID d290f1ee-6c54-4b01-90e6-d701748f0851 in the analytics dashboard with Linked visitors open: 9 Visitor IDs, each with its identifications and band." width="2254" height="1658" data-path="images/dashboard/device-card-linked-visitors.png" />

      <img className="hidden dark:block" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/device-card-linked-visitors-dark.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=9f69fbd139533b2f3fa7b4621a799f7c" alt="The device card for Device ID d290f1ee-6c54-4b01-90e6-d701748f0851 in the analytics dashboard in the dark theme with Linked visitors open: 9 Visitor IDs, each with its identifications and band." width="2254" height="1658" data-path="images/dashboard/device-card-linked-visitors-dark.png" />
    </Frame>
  </Step>

  <Step title="Fall back for views the Device ID meter cannot cover">
    Some views carry no usable Device ID, and pretending otherwise leaks free views:

    * **JavaScript off, or the snippet blocked or not loaded.** The snippet cannot run, so your page gets no request ID and no webhook arrives. Count the view by cookie or IP where you serve the article.
    * **An all-zero Device ID.** An all-zero Device ID (`00000000-0000-0000-0000-000000000000`) means no usable device signals reached ShieldLabs for that identification; the rate-limit marker (Risk Score 999) is one such case. Meter that view by cookie or IP as well, rather than serving it free; meter the rate-limit marker by cookie only, since it can land on many readers behind one shared IP.

    Headless and automated browsers that do run the snippet get a normal Device ID and raise the `javascript_disabled` or `browser_automation` risk signal, so they count on the device meter like any other reader. To wall them outright, check `risk.detection_flags?.browser_automation` before you count.

    The fallback meter keys on the IP your own server sees for the request, since these views carry no identification. For views that do carry one, the Local IP (`local_ip.ip`, the address the browser itself reports) can stay put while a VPN hands out a fresh public IP on demand, so it is the steadier per-network key when present.

    The other limit is by design: the Device ID belongs to one browser on one device, so the same reader on Chrome and on Firefox is two Device IDs, and a determined reader can earn fresh free views by switching browsers or machines. That is a far higher bar than clearing cookies. To raise it further, pair the per-device counter with a per-IP cap and a soft cookie meter, and require a free account for continued access once any of them trips. A signed-in reader is then metered per account, as the next step shows.
  </Step>

  <Step title="Meter signed-in readers by account">
    A signed-in reader is an account, and an account can reach you from several devices. On signed-in pages, call `forceCheckAuthenticatedUser` with the hashed User HID, and count free views per account as well as per device, so a reader cannot earn fresh views by switching browsers while signed in.

    ```js theme={null}
    // Inside /api/meter, after the per-device count and before `allow`.
    if (userHid) {
      const accountViews = await incrementAccountViews(userHid, articleId); // your store
      if (accountViews > FREE_LIMIT) {
        return res.json({ action: 'paywall', reason: 'free_limit_reached', views: accountViews });
      }
    }
    ```

    For subscribers, the Account sharing event below covers the other half: one account used from several distinct devices.
  </Step>

  <Step title="Tune to your product">
    Start by logging the per-device and per-account distribution of your real readers before you enforce, so your free limit matches how people actually read rather than a guess.
  </Step>
</Steps>

To catch readers who slip the per-device meter by opening fresh free accounts or sharing one subscription, pass the hashed User HID with `forceCheckAuthenticatedUser` on signed-in article pages, as the meter above does. ShieldLabs detects [Multi-accounting](/features/high-risk-events#multi-accounting) on your users directly: several accounts run by one person, linked through the devices and network they share, the shape of a reader opening fresh free accounts. It also detects [Account sharing](/features/high-risk-events#account-sharing): one account used from several distinct devices, the shape of one subscription passed around a household or a team. Each carries **Medium** or **High** confidence. High-Risk Events are available in the analytics dashboard, the API and webhooks, and need the hashed User HID you pass on signed-in pages. The Risk Score and risk signals of the identification remain the input when you serve or wall an article. When one of these events arrives for a reader through the API or webhooks, or when you review it in the analytics dashboard, act on the account. You choose the action for each case, for example applying your sharing policy at its next sign-in. A reader who switches to an anti-detect browser to look new without signing in raises the Anti-detect Browser [risk signal](/features/risk-signals) instead.

On the account's [user card](/dashboard/entity-card) in the analytics dashboard, the Account sharing pill's colour gives its confidence (red for High, orange for Medium, the colours of the Dangerous and Suspicious bands, though the pill is an event), and **Linked devices** lists each device with the band of the account's identifications on it.

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

| Reset attempt | Device ID meter result |
| - | - |
| Clear cookies | Count holds |
| Incognito or private window | Count holds |
| New network or VPN | Count holds |
| Different browser, same machine | New count (a new Device ID; the account meter holds for a signed-in reader) |
| Different device | New count (a new Device ID; the account meter holds for a signed-in reader) |
| JavaScript off or snippet blocked | No request ID and no webhook: fall back to cookie or IP |

## Test it

Confirm the meter holds before you enforce. Read past your free limit in a normal window, then try the resets a reader would try:

<Steps>
  <Step title="Clear cookies and reload">
    Read enough articles to trip the wall, clear cookies, and reload. The `device_id` in the webhook is the same one, so your per-device count keeps climbing instead of resetting to zero.
  </Step>

  <Step title="Open the article in incognito">
    Open the same page in a private window. The `cookie_id` and `visitor_id` change, but the `device_id` returns identical: your meter keys off the durable handle.
  </Step>

  <Step title="Switch to a second browser">
    Open the article in a different browser on the same machine. Here the `device_id` *does* change, because another browser is another Device ID. That is the honest limit: the per-IP cap slows it, and signing in brings the account meter into play.
  </Step>
</Steps>

<Note>
  Test as a real reader resetting their own browser. An automated client raises the Browser Automation risk signal, and a fetch with scripts off never runs the snippet, so neither exercises the device meter.
</Note>

## Recommended starting policy

A guide, not a rule. The right limit depends on your content and your conversion goals.

| Situation | Suggested action |
| - | - |
| Device under the free limit | Serve the article, increment the count |
| Device at or over the free limit | Show the paywall |
| Signed-in account at or over the free limit | Show the paywall, whatever the device count |
| No identification or no usable Device ID (JavaScript off, snippet blocked) | Meter by cookie or IP for this view, do not serve free forever |
| Many views with no identification from one IP | Treat as one reader with scripts off, apply the IP cap |
| Same reader across two browsers | Accept as the browser-bound limit, or add a per-IP cap to slow it |

## Where to go next

To understand why the Device ID holds through cookie clears and incognito while the Visitor ID does not, read [Identifiers](/features/identification), and [Users, devices, visitors and IPs](/concepts/entities) shows how an account links to the devices it reads from. The [risk signals](/features/risk-signals) reference covers the masking and automation signals that travel alongside the Device ID. The exact `device_id` field and the rest of the identification live in the [webhook payload](/api/webhooks) your meter reads. The [Billing](/billing) page covers the included identifications on each plan.

The same Device ID powers neighboring playbooks: [Ban Enforcement](/use-case/ban-evasion) keys a banlist on the device a returning reader cannot wipe, and [Returning Visitor](/use-case/returning-visitor) recognizes a known account on a device it has used before. To grade where your readers come from, [Measure Traffic Quality](/use-case/traffic-quality) grades each acquisition source by the risky users, devices and traffic it brings.


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