Skip to main content
Card testing is a volume game: a tester runs many small charges through your checkout to learn which stolen cards still work. The defense is to recognize the same device behind a string of “fresh” attempts, even as the card, cookies, and IP all change. ShieldLabs gives you a durable device id and the anonymity picture for each session; your checkout code counts the attempts and decides when to add friction. Left unchecked, a run buries you in declines and chargeback fees and can flag your account with the card networks, so catching the device behind it early is what keeps your decline rate clean.

What is card testing?

Card testing (also called carding, card cracking, or card checking) is the rapid validation of stolen card numbers by pushing many low-value or zero-value authorizations through a checkout to see which cards are still live. Some testers push a tiny charge; others use a zero-dollar authorization that is slower to surface, so a run can stay quiet for a while. The tell is repetition without a real buyer behind it: a burst of attempts in a short window, many declines, and often a masked or rotating connection so each try looks like a different person. It clusters on low-friction, low-ticket flows — guest checkouts, digital goods, gaming top-ups, and donation forms — where a small charge looks unremarkable.

How ShieldLabs surfaces it

ShieldLabs resolves each checkout session to a set of identifiers and returns a Risk Score with named anonymity signals. It identifies the session — the device behind the browser and how anonymous the connection is. The card number, the BIN, and the authorization result stay with your payment processor; ShieldLabs reads the session, so a tester swapping cards still resolves to the same device. A naive checkout keys its attempt counter on the cookie, the session, or the buyer’s IP — all three reset for free, so a tester clears cookies, opens incognito, or rotates to a fresh proxy IP and each try reads as a brand-new shopper. The durable DeviceID is derived server-side, so it survives cleared cookies, incognito, and IP rotation and recognizes the same device behind a run of attempts. That stable anchor is what makes counting possible.
ShieldLabs gives you a device id that holds steady, so your code counts attempts per device, in your own datastore, against your own limit. The split is the whole point: ShieldLabs identifies the session; your code does the counting and owns the verdict.

Prevent card testing

The rule your code applies: at each payment attempt, identify the session, then read the durable device_id, the anonymity signals, and the Risk Score (0–100) from the webhook or History. In your own store, increment an attempt counter keyed on that device_id (and on local_ip.ip for the network view), and add friction — 3-D Secure, a hold, or a step-up — when the per-device count crosses your own limit, or when the session is masked (a datacenter IP, proxy, Tor, an anti-detect browser, or an ip_mismatch flag). The outcome is that a tester swapping cards behind cleared cookies and a fresh proxy still resolves to one device, so your code can let a clean first attempt through and clamp down once the same device starts cycling cards. ShieldLabs surfaces the device and the signals; your checkout code owns the limit and the verdict. The steps below wire it up.

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

Force a fresh check at the payment step

On earlier pages you may already run a check for analytics. At the payment step you want a current read, so use the forceCheck* variant: it clears the session and runs a new identify call immediately, scoring the session as it is at payment time. Pass a hashed user id, never a raw email or account id.
checkout.html
<script type="module">
  const mod = await import(
    'https://cdn.shieldlabs.ai/snippet.js?publicKey=YOUR_PUBLIC_KEY'
  );
  // The browser does NOT compute the Risk Score. The callback's first arg
  // is the client IP the server saw (not a score). Keep the requestID:
  // it is your join key to the webhook you receive server-side.
  mod.forceCheckAuthenticatedUser('a1b2c3d4hasheduserid', (ip, requestID) => {
    document.getElementById('shield-request-id').value = requestID;
  });
</script>

<form id="checkout-form" method="POST" action="/api/pay">
  <input type="hidden" id="shield-request-id" name="shieldRequestId" />
  <!-- payment fields -->
  <button type="submit">Pay</button>
</form>
For a guest checkout with no account, use forceCheckAnonymous instead; installing the snippet covers the React, Vue, Angular, Preact, and Svelte versions of the same dynamic-import pattern.
3

Read the device and count attempts in your own store

Pull the scored result for that RequestID with the shared waitForScore helper, which polls your webhook cache and falls back to the History API. Then do the counting on your side: increment a per-device_id attempt counter in your datastore and compare it to your own limit. This counter is your velocity, in your data — ShieldLabs supplies the stable anchor to count on.
api/pay.js
app.post('/api/pay', async (req, res) => {
  const { shieldRequestId } = req.body;

  // Pull the ShieldLabs result for this session (snake_case, as the
  // webhook and account History API deliver it).
  const shield = await waitForScore(shieldRequestId, 2000);

  // No result yet is not the same as "clean". Hold rather than charge blind.
  if (!shield) {
    return res.status(202).json({ status: 'review', reason: 'verifying' });
  }

  const deviceId = shield.device_id;

  // A blocked or JS-disabled session can return an all-zero DeviceID.
  // That is "device unknown", not a fresh device: weigh it with IP context
  // instead of resetting the counter to one.
  const ZERO_DEVICE = '00000000-0000-0000-0000-000000000000';
  if (!deviceId || deviceId === ZERO_DEVICE) {
    return res.status(202).json({ status: 'step_up', method: '3ds' });
  }

  // YOUR velocity: count this attempt against the durable device id.
  // This counter lives in your datastore; ShieldLabs supplies the device id.
  const attempts = await bumpAttemptCount(deviceId);            // your store
  await bumpAttemptCount('ip:' + shield.local_ip?.ip);       // network view

  if (attempts > YOUR_ATTEMPT_LIMIT) {
    return res.status(202).json({ status: 'step_up', reason: 'card_testing' });
  }

  // First clean attempts pass to the masking check below, then to the charge.
  return decideOnSignals(shield, res);
});
Track declines the same way if your processor returns them: declines are the tell, not noise, because a tester reads each rejection reason and retries, so one device returning a string of declines against fresh cards is the classic card-testing shape. Only your code sees the payment result, so count declines per device_id alongside attempts.
4

Add friction to masked sessions, regardless of count

A first attempt has no history yet, so the count alone will not catch the opening move of a run. The anonymity picture catches it on the spot: card testing usually rides a masked connection. Branch on the detection_flags object — its keys are stable booleans, safer than parsing the human-readable signals labels.
function decideOnSignals(shield, res) {
  const f = shield.detection_flags;

  // Masked session at the payment step: step up before the charge.
  if (f.tor || f.anti_detect_browser || f.abuser ||
      f.datacenter_ip || f.proxy || f.ip_mismatch) {
    return res.status(202).json({ status: 'step_up', method: '3ds' });
  }

  // Otherwise lean on the band; tighten it at payment.
  if (shield.score >= 30) {
    return res.status(202).json({ status: 'step_up', method: '3ds' });
  }
  return res.status(202).json({ status: 'allow' });
}
Read the real network behind a VPN. The webhook carries two IPs. public_ip.country comes from the public IP, which a proxy or VPN can put anywhere; local_ip.country is the real network IP, which can expose the network behind the mask. When the two disagree, detection_flags.ip_mismatch is set to true, so your code can act on the gap. Counting per local_ip.ip as well as per device_id catches a run spread across several devices but one real network.
A legitimate buyer can score high. Corporate VPNs, privacy browsers, and Privacy Relay all raise the Risk Score for real customers, so a single VPN or Privacy Relay signal is weaker evidence than Tor, Anti-detect Browser, or Abuser Flag. Decide on the Score plus the signals plus your per-device count, never one number alone.
5

Corroborate the run on the dashboard over time

The per-request decision above is in-the-moment. The pattern that confirms a card-testing operation builds up over hours: one device, or one local network, cycling through account after account. Patterns grade each entity Suspicious, then Dangerous, as the linked count crosses thresholds in a rolling window, and levels never downgrade once flagged.
Many distinct accounts driven from a single device. A tester running stolen cards through guest checkouts or throwaway accounts produces this shape. The grouping identity is the device_id.
Many accounts originating from one real network IP. Catches a run spread across several devices on the same connection. The grouping identity is local_ip.ip.
These live on the dashboard Patterns tab only and are not in the checkout webhook. Export them as your watchlist, or reconstruct the same counts live from the History API:
Count accounts per device
async function accountsForDevice(deviceId) {
  const rows = await shieldHistory('device_id', deviceId, 100);
  const accounts = new Set(rows.map((r) => r.user_hid).filter(Boolean));
  return accounts.size;
}
Account History reads on account.shieldlabs.ai and webhook delivery are free; the Management History path bills 1 request per returned row. For routine watching, use the dashboard pattern export and reserve billed reads for devices you are actively investigating.

Test it

Confirm the durable DeviceID holds before you wire your attempt limit to live charges. Run one checkout, note the device_id from the webhook, then repeat the visit in an incognito window, after clearing cookies, and from a second browser on the same machine: the cookie_id and visitor_id change each time, but the device_id stays the same — that is the anchor your per-device counter sits on, so three “fresh” tries from one tester increment one counter, not three. Then repeat through a VPN or proxy and watch the signals array gain a VPN or Proxy entry with a higher Score, the same masking your friction rule keys on.

Next steps

Card testing is the front edge of payment fraud at checkout; the same fresh-check pattern and the same device_id carry through from validating a stolen card to pushing the real purchase. And because a tester farming many throwaway accounts is the same durable device behind each one, the device link that powers your attempt counter also surfaces one person running many accounts.

Acting on the Risk Score

Turn the Score and signals into allow, challenge, review, and hold logic in your app.

Signals

Every signal that can appear in signals, in plain language, with its weight.

The Risk Score

How the 0 to 100 score is built, what signals carries, and the band definitions.

Payment fraud at checkout

The next step: read device and anonymity signals before you charge.