Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 044 / 100

Card testing attacks: a practical Payments & Stripe guide

Stop fraudsters testing stolen cards.

Expona Admin8 min read

Card testing attacks: a practical Payments & Stripe guide

Fraudsters don't need your password or your account details to drain your business—they just need a list of stolen card numbers and a way to validate them. Card testing attacks are systematic attempts to verify which cards in a stolen deck are still active, usually through small repeated charges that slip under the radar. We've seen teams lose thousands in processing fees and chargeback disputes simply because they didn't recognize the pattern until it was too late.

In this guide, we'll walk through proven defenses: rate limiting, CAPTCHA challenges, Stripe Radar rules, and authentication gates. You'll see exactly how to implement each one and how they work together to stop fraudsters testing stolen cards before they graduate to larger charges.

Understanding card testing attacks

Card testing typically looks like this: a fraudster submits 50 card numbers over 20 minutes from a single IP address, each for a $1 charge. Most fail, but a few succeed—confirming those cards are live. They then sell the verified numbers to other criminals or use them themselves for bigger frauds. The attacker's goal is fast, high-volume validation with minimal friction.

From Stripe's vantage point, these are noisy transactions: same merchant, multiple cards, small amounts, rapid fire. But if your rate limiting and rules aren't in place, you're paying the interchange fee, the processing fee, and eventually the chargeback fee when the cardholder notices.

Rate limits per IP and account

The first line of defense is simple velocity checks. If one IP sends 10 payment requests in 2 minutes, something is wrong. That's not a user, that's a bot. Implementing IP-based rate limiting is straightforward and catches the obvious cases immediately.

In a Node.js / NestJS backend, you'd typically use Redis to track requests:

typescript
import { Injectable } from '@nestjs/common';
import { RedisService } from './redis.service';

@Injectable()
export class RateLimitService {
  constructor(private redis: RedisService) {}

  async checkIPLimit(ip: string, limit: number = 10, windowSeconds: number = 60): Promise {
    const key = `rate:ip:${ip}`;
    const count = await this.redis.incr(key);
    
    if (count === 1) {
      await this.redis.expire(key, windowSeconds);
    }
    
    return count <= limit;
  }

  async checkAccountLimit(customerId: string, limit: number = 5, windowSeconds: number = 60): Promise {
    const key = `rate:account:${customerId}`;
    const count = await this.redis.incr(key);
    
    if (count === 1) {
      await this.redis.expire(key, windowSeconds);
    }
    
    return count <= limit;
  }
}

Reject any request that exceeds the limit. For legitimate users who hit the limit by accident, they'll retry in a minute. For card testers, you've just blocked hundreds of attempts. Also rate limit by account or user ID—a compromised merchant account shouldn't be able to send unlimited payment requests either, and one customer shouldn't submit 20 different cards in rapid succession.

Tune these limits based on your traffic patterns. An e-commerce checkout might allow 3–5 attempts per minute per IP. A subscription processor with legitimate retry logic might allow higher limits but track failures separately.

CAPTCHAs on suspicious traffic

Rate limiting blocks the obvious floods, but what if the attacker spreads requests across multiple IPs or uses a botnet? Then you need a behavioral challenge. A CAPTCHA—especially when combined with rate limits and other signals—forces an attacker to slow down and increase their cost.

You don't need to CAPTCHA every request. Reserve it for suspicious patterns:

  • Multiple failed payment attempts from the same session
  • High-velocity requests from a new user with no account history
  • Different cards being tested from the same device fingerprint
  • Geographic anomalies (card country doesn't match IP country)

In a React checkout flow with Stripe Elements, you'd render the CAPTCHA conditionally:

typescript
import { useState } from 'react';
import ReCAPTCHA from 'react-google-recaptcha';

export function CheckoutForm({ isSuspicious }) {
  const [captchaToken, setCaptchaToken] = useState(null);

  const handleSubmit = async (e) => {
    e.preventDefault();

    if (isSuspicious && !captchaToken) {
      alert('Please complete the CAPTCHA');
      return;
    }

    const response = await fetch('/api/payments/create', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        amount: 1000,
        currency: 'usd',
        captchaToken,
      }),
    });

    // Handle response...
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Card input */}
      {isSuspicious && (
        <ReCAPTCHA
          sitekey={process.env.REACT_APP_RECAPTCHA_SITE_KEY}
          onChange={setCaptchaToken}
        />
      )}
      <button type='submit'>Pay</button>
    </form>
  );
}

On the backend, validate the CAPTCHA token before processing:

typescript
async function verifyCaptcha(token: string): Promise {
  const response = await fetch('https://www.google.com/recaptcha/api/siteverify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: `secret=${process.env.RECAPTCHA_SECRET_KEY}&response=${token}`,
  });

  const data = await response.json();
  return data.success && data.score > 0.5;
}

The CAPTCHA signals intent—a real user will solve it, a bot usually won't. You've added friction without breaking the experience for legitimate traffic.

Radar rules for small repeated charges

Stripe Radar is your partner in this fight. It learns from millions of transactions and can flag risky patterns. But it also lets you write custom rules for your specific business. The classic card testing pattern is small charges to many different cards, so create a rule that watches for it.

In the Stripe dashboard, navigate to Radar > Rules and add a rule like this:

Condition Value
Card fingerprint is unique in
Amount < $2
Time window 5 minutes
Threshold > 5 occurrences
Action Block & Review

This rule says: if you see more than 5 transactions under $2 with different card fingerprints in 5 minutes, block them and alert the team. Adjust the thresholds for your business—a travel platform might see 20 small transactions in 10 minutes from one user booking flights on different payment methods, so you'd need context-aware rules.

You can also programmatically add rules using the Stripe API, though the dashboard is usually clearer for team collaboration:

typescript
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

await stripe.radar.radarRules.create({
  type: 'card_velocity',
  name: 'Card testing: many small charges',
  block: 'block',
  predicate: {
    type: 'and',
    children: [
      {
        type: 'lt',
        attributeName: 'charge.amount',
        value: 200, // $2.00 in cents
      },
      {
        type: 'gt',
        attributeName: 'charge.fraud_indicators.count_in_last_5_minutes',
        value: 5,
      },
    ],
  },
});

Radar also provides fraud indicators on each charge object. Check them in your backend logic:

typescript
const charge = await stripe.charges.create({
  amount: 100,
  currency: 'usd',
  source: token,
});

if (charge.fraud_details && charge.fraud_details.user_report === 'fraudulent') {
  console.log('Charge flagged as fraudulent by Radar');
}

console.log(charge.outcome.risk_level); // elevated, normal, etc.

Use these signals to block, require additional verification, or log for later review. Radar rules give you a scalable way to catch patterns without writing custom detection logic for every scenario.

Require login before payment

Anonymous checkout is convenient, but it's also a gift to card testers. They can submit dozens of card numbers without any accountability. A simple gate—require users to log in or create an account before paying—raises the friction dramatically.

If the attacker has to create 100 accounts to test 100 cards, their operation becomes expensive in time and IP diversity. Most will move on to easier targets. And once they log in, you have a persistent identity to track across attempts.

In a Next.js app, this might look like:

typescript
export async function getServerSideProps(context) {
  const session = await getSession(context);

  if (!session) {
    return {
      redirect: {
        destination: '/login',
        permanent: false,
      },
    };
  }

  return {
    props: { user: session.user },
  };
}

export default function CheckoutPage({ user }) {
  return (
    <div>
      <h1>Checkout</h1>
      <p>Logged in as {user.email}</p>
      {/* Payment form */}
    </div>
  );
}

Yes, this costs you some conversion on legitimate users who don't have an account yet. But most of them will sign up in 30 seconds. The fraud you prevent—and the chargebacks you avoid—pays for the lost conversions many times over.

If you absolutely must allow guest checkout, require email verification first and rate limit by email domain. This at least ensures that someone can't test 100 cards using 100 fake @example.com addresses.

Common mistakes

Relying on Stripe Radar alone. Radar is powerful, but it's reactive and global. Your custom rules and rate limits are proactive and specific to your business. Use both.

Setting rate limits too high. If you allow 100 requests per minute per IP, you've essentially disabled the defense. Start conservative (5–10 per minute) and loosen only if you see false positives from real users.

Not tracking failed attempts. A failed payment doesn't mean the card is dead—it might be a temporary decline or a fraud hold. An attacker will retry. Count failures per card or per session and escalate after 3–5 attempts, even if some succeed.

Forgetting about account velocity. Attacker creates 10 accounts in one day and tests 10 different cards on each. Your IP rate limit didn't catch it because each request came from a different account. You need per-account limits too.

Ignoring geographic signals. A card issued in Japan being tested from an IP in Russia is suspicious. Stripe's risk indicators include this; compare card country to IP country and user location before payment.

Key takeaways

  • Implement IP-based and account-based rate limits in Redis. Start with 5–10 requests per minute and adjust based on your traffic.
  • Trigger CAPTCHA challenges on suspicious patterns: rapid failed attempts, new users with many cards, high velocity from a single session.
  • Write Stripe Radar rules for your specific business. Watch for multiple small charges to different cards within a tight time window.
  • Require authentication before payment. Guest checkout is a luxury; fraud prevention is a necessity. If you must allow guest checkout, require email verification and enforce strict limits.
  • Monitor Radar fraud indicators and charge outcomes in your backend. Block or escalate charges with elevated risk signals immediately.
  • Log all suspicious activity—failed attempts, CAPTCHA triggers, blocked transactions—and review regularly for new patterns. Card testers adapt; so should you.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles