How to Stop VPN Coupon Abuse (Without Blocking Real Customers)
Tutorials20 min read

How to Stop VPN Coupon Abuse (Without Blocking Real Customers)

Comprehensive guide to preventing regional pricing fraud while maintaining a smooth experience for legitimate international customers.

Mantas Karmaza

Mantas Karmaza

Founder · October 15, 2023

How to Stop VPN Coupon Abuse (Without Blocking Real Customers)

Regional pricing is great until someone in San Francisco uses a VPN to get your India pricing. This guide shows you exactly how to stop fraud while keeping the door wide open for legitimate international customers.

!Security Shield

The $1.2 Million Problem

The True Cost of Coupon Fraud

Example: SaaS with regional pricing

Annual regional pricing revenue: $500,000
Without fraud protection: 15-20% fraud rate
Lost to fraud: $75,000-$100,000/year

With proper protection: <1% fraud rate
Lost to fraud: <$5,000/year

Savings: $70,000-$95,000/year

How Fraud Happens

The typical fraud flow:

1. User in USA sees $99/month price
2. Connects to VPN server in India
3. Page reloads, sees "Special India pricing: $35/month"
4. Copies coupon code: PPP-IN-65
5. Purchases with US credit card
6. Uses product from California
7. (Optionally) Shares code on Reddit

Where Fraudsters Share Codes

PlatformRisk LevelHow Codes Spread
Reddit r/dealsVery HighScreenshot posts
Twitter/XHighViral threads
DiscordHighPrivate servers
TelegramVery HighDeal channels
SlickdealsHighForum posts
Personal blogsMedium"Life hacks" articles

Ready to increase your international revenue?

Start your free trial and see results in days, not months.

Start Free Trial

The Fraud Detection Stack

Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                    6-Layer Protection                        │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  Layer 1: IP Geolocation (baseline)                         │
│     ↓                                                        │
│  Layer 2: VPN/Proxy Detection (essential)                   │
│     ↓                                                        │
│  Layer 3: Tor Exit Node Blocking (essential)                │
│     ↓                                                        │
│  Layer 4: Behavioral Analysis (advanced)                    │
│     ↓                                                        │
│  Layer 5: Device Fingerprinting (advanced)                  │
│     ↓                                                        │
│  Layer 6: Card Country Verification (strongest)             │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Layer 1: IP Geolocation (Baseline)

The foundation—determine where the IP claims to be:

// lib/geolocation.ts
interface GeoResult {
  country: string
  city: string
  isp: string
  asn: string
  isHosting: boolean
}

async function getIPGeolocation(ip: string): Promise<GeoResult> {
  // Use multiple providers for accuracy
  const providers = [
    `https://ipapi.co/${ip}/json/`,
    `https://ipinfo.io/${ip}?token=${IPINFO_TOKEN}`,
  ]

  for (const url of providers) {
    try {
      const response = await fetch(url)
      const data = await response.json()

      return {
        country: data.country_code || data.country,
        city: data.city,
        isp: data.org || data.isp,
        asn: data.asn,
        isHosting: isHostingProvider(data.org || data.isp)
      }
    } catch (e) {
      continue
    }
  }

  throw new Error('All geolocation providers failed')
}

function isHostingProvider(org: string): boolean {
  const hostingProviders = [
    'amazon', 'aws', 'google', 'microsoft', 'azure',
    'digitalocean', 'linode', 'vultr', 'ovh', 'hetzner'
  ]
  return hostingProviders.some(p => org.toLowerCase().includes(p))
}

Layer 2: VPN/Proxy Detection (Essential)

Detect when users are masking their real location:

// lib/vpn-detection.ts
interface VPNCheckResult {
  isVPN: boolean
  isProxy: boolean
  isTor: boolean
  isHosting: boolean
  isRelay: boolean
  confidence: number
  provider?: string
}

async function detectVPN(ip: string): Promise<VPNCheckResult> {
  // Option 1: IPInfo Privacy Detection (Recommended)
  const ipinfoResult = await checkIPInfo(ip)

  // Option 2: IP2Location
  const ip2Result = await checkIP2Location(ip)

  // Option 3: Custom database check
  const customResult = await checkCustomDatabase(ip)

  // Combine results for higher accuracy
  return {
    isVPN: ipinfoResult.vpn || ip2Result.vpn,
    isProxy: ipinfoResult.proxy || ip2Result.proxy,
    isTor: ipinfoResult.tor || ip2Result.tor,
    isHosting: ipinfoResult.hosting || ip2Result.hosting,
    isRelay: ipinfoResult.relay || false,
    confidence: calculateConfidence([ipinfoResult, ip2Result, customResult]),
    provider: ipinfoResult.vpnProvider || ip2Result.vpnProvider
  }
}

async function checkIPInfo(ip: string): Promise<any> {
  const response = await fetch(
    `https://ipinfo.io/${ip}?token=${process.env.IPINFO_TOKEN}`
  )
  const data = await response.json()

  return {
    vpn: data.privacy?.vpn || false,
    proxy: data.privacy?.proxy || false,
    tor: data.privacy?.tor || false,
    hosting: data.privacy?.hosting || false,
    relay: data.privacy?.relay || false,
    vpnProvider: data.privacy?.service || null
  }
}

!Network Security

Layer 3: Tor Exit Node Blocking (Essential)

Block Tor exit nodes—there's no legitimate reason to browse pricing pages via Tor:

// lib/tor-detection.ts
let torExitNodes: Set<string> = new Set()

// Refresh every hour
async function refreshTorExitNodes() {
  try {
    const response = await fetch('https://check.torproject.org/exit-addresses')
    const text = await response.text()

    const ips = text
      .split('\n')
      .filter(line => line.startsWith('ExitAddress'))
      .map(line => line.split(' ')[1])

    torExitNodes = new Set(ips)
    console.log(`Loaded ${torExitNodes.size} Tor exit nodes`)
  } catch (e) {
    console.error('Failed to refresh Tor exit nodes:', e)
  }
}

function isTorExitNode(ip: string): boolean {
  return torExitNodes.has(ip)
}

// Alternative: Use Tor DNS lookup
async function checkTorDNS(ip: string): Promise<boolean> {
  // Reverse the IP and query the Tor DNS
  const reversed = ip.split('.').reverse().join('.')
  try {
    const lookup = await dns.lookup(`${reversed}.dnsel.torproject.org`)
    return lookup.address === '127.0.0.2' // Tor exit node indicator
  } catch {
    return false
  }
}

Layer 4: Behavioral Analysis (Advanced)

Detect suspicious patterns that indicate fraud:

// lib/behavior-analysis.ts
interface BehaviorSignals {
  ip: string
  sessionId: string
  userAgent: string
  timezone: string
  language: string
  screenResolution: string
  countryFromIP: string
  countryFromTimezone: string
  requestsPerMinute: number
  previousCountries: string[]
  accountAge: number
}

interface RiskAssessment {
  score: number        // 0-100, higher = more risky
  flags: string[]
  recommendation: 'allow' | 'verify' | 'block'
}

function analyzeBehavior(signals: BehaviorSignals): RiskAssessment {
  const flags: string[] = []
  let score = 0

  // Flag 1: Timezone doesn't match IP country
  if (signals.countryFromIP !== signals.countryFromTimezone) {
    flags.push('timezone_mismatch')
    score += 25
  }

  // Flag 2: Browser language doesn't match country
  const expectedLanguages = getExpectedLanguages(signals.countryFromIP)
  if (!expectedLanguages.includes(signals.language.slice(0, 2))) {
    flags.push('language_mismatch')
    score += 15
  }

  // Flag 3: Rapid country switching
  if (signals.previousCountries.length > 1) {
    const uniqueCountries = new Set(signals.previousCountries)
    if (uniqueCountries.size > 2) {
      flags.push('country_hopping')
      score += 30
    }
  }

  // Flag 4: High request rate
  if (signals.requestsPerMinute > 10) {
    flags.push('high_request_rate')
    score += 20
  }

  // Flag 5: New account trying high discount
  if (signals.accountAge < 60) { // 60 seconds
    flags.push('new_account')
    score += 10
  }

  // Flag 6: Known VPN user agent patterns
  if (isVPNUserAgent(signals.userAgent)) {
    flags.push('vpn_user_agent')
    score += 20
  }

  return {
    score,
    flags,
    recommendation: score < 30 ? 'allow' : score < 60 ? 'verify' : 'block'
  }
}

function getExpectedLanguages(country: string): string[] {
  const languageMap: Record<string, string[]> = {
    IN: ['en', 'hi', 'ta', 'te', 'bn'],
    BR: ['pt'],
    DE: ['de'],
    FR: ['fr'],
    JP: ['ja'],
    US: ['en', 'es'],
    // ... add more
  }
  return languageMap[country] || ['en']
}

Layer 5: Device Fingerprinting (Advanced)

Create a device fingerprint to track repeat offenders:

// lib/fingerprinting.ts
// Client-side (runs in browser)
async function generateFingerprint(): Promise<string> {
  const components = [
    navigator.userAgent,
    navigator.language,
    screen.width + 'x' + screen.height,
    screen.colorDepth,
    new Date().getTimezoneOffset(),
    navigator.hardwareConcurrency,
    navigator.deviceMemory || 'unknown',
    getWebGLFingerprint(),
    await getCanvasFingerprint(),
    getAudioFingerprint(),
    getFontFingerprint(),
  ]

  const fingerprint = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(components.join('|'))
  )

  return Array.from(new Uint8Array(fingerprint))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('')
}

// Server-side tracking
interface FingerprintRecord {
  fingerprint: string
  firstSeen: Date
  lastSeen: Date
  countries: string[]
  couponsUsed: string[]
  riskScore: number
}

async function checkFingerprint(fingerprint: string): Promise<FingerprintRecord | null> {
  // Check your database
  return await db.fingerprintRecord.findUnique({
    where: { fingerprint }
  })
}

async function flagFraudulentFingerprint(fingerprint: string, reason: string) {
  await db.fingerprintRecord.update({
    where: { fingerprint },
    data: {
      riskScore: { increment: 25 },
      flags: { push: reason }
    }
  })
}

Layer 6: Card Country Verification (Strongest)

The nuclear option—verify at payment time:

// lib/card-verification.ts
import Stripe from 'stripe'

interface VerificationResult {
  matches: boolean
  expectedCountry: string
  cardCountry: string
  action: 'proceed' | 'remove_discount' | 'refund'
}

async function verifyCardCountry(
  paymentIntentId: string,
  expectedCountry: string
): Promise<VerificationResult> {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

  const paymentIntent = await stripe.paymentIntents.retrieve(
    paymentIntentId,
    { expand: ['payment_method'] }
  )

  const paymentMethod = paymentIntent.payment_method as Stripe.PaymentMethod
  const cardCountry = paymentMethod.card?.country

  if (!cardCountry) {
    return {
      matches: false,
      expectedCountry,
      cardCountry: 'unknown',
      action: 'remove_discount'
    }
  }

  // Allow some flexibility for neighboring countries
  const allowedCountries = getNeighboringCountries(expectedCountry)

  const matches = cardCountry === expectedCountry ||
    allowedCountries.includes(cardCountry)

  return {
    matches,
    expectedCountry,
    cardCountry,
    action: matches ? 'proceed' : 'remove_discount'
  }
}

function getNeighboringCountries(country: string): string[] {
  // Some regional groupings that make sense
  const groups: Record<string, string[]> = {
    // EU countries - cards from any EU country are ok
    EU: ['DE', 'FR', 'IT', 'ES', 'NL', 'BE', 'AT', 'PL', 'PT', 'GR', 'CZ', 'HU'],
    // North America
    NA: ['US', 'CA'],
    // South Asia
    SA: ['IN', 'BD', 'NP', 'LK'],
  }

  for (const [region, countries] of Object.entries(groups)) {
    if (countries.includes(country)) {
      return countries
    }
  }

  return [country]
}

!Secure Payment

Putting It All Together

The Decision Engine

// lib/fraud-engine.ts
interface FraudDecision {
  allowed: boolean
  discount: number      // 0 if blocked
  requireVerification: boolean
  message: string
  riskScore: number
  flags: string[]
}

async function evaluatePurchaseRequest(
  ip: string,
  fingerprint: string,
  requestedCountry: string,
  requestedDiscount: number,
  sessionData: any
): Promise<FraudDecision> {
  const results = await Promise.all([
    getIPGeolocation(ip),
    detectVPN(ip),
    isTorExitNode(ip),
    analyzeBehavior(sessionData),
    checkFingerprint(fingerprint),
  ])

  const [geo, vpn, isTor, behavior, fingerprintRecord] = results

  // Aggregate risk score
  let riskScore = behavior.score
  const flags: string[] = [...behavior.flags]

  // Tor = automatic block
  if (isTor) {
    return {
      allowed: false,
      discount: 0,
      requireVerification: false,
      message: 'This discount is not available via Tor',
      riskScore: 100,
      flags: ['tor_detected']
    }
  }

  // VPN detected
  if (vpn.isVPN || vpn.isProxy) {
    flags.push('vpn_detected')
    riskScore += 30

    // For VPN users, require card verification
    if (requestedDiscount > 20) {
      return {
        allowed: true,
        discount: requestedDiscount,
        requireVerification: true,
        message: 'VPN detected. Your discount will be verified at checkout.',
        riskScore,
        flags
      }
    }
  }

  // Hosting provider IP
  if (geo.isHosting) {
    flags.push('hosting_ip')
    riskScore += 20
  }

  // Country mismatch
  if (geo.country !== requestedCountry) {
    flags.push('country_mismatch')
    riskScore += 40
  }

  // Known bad fingerprint
  if (fingerprintRecord && fingerprintRecord.riskScore > 50) {
    flags.push('known_bad_device')
    riskScore += fingerprintRecord.riskScore
  }

  // Make decision
  if (riskScore >= 70) {
    return {
      allowed: false,
      discount: 0,
      requireVerification: false,
      message: 'This discount is not available for your location',
      riskScore,
      flags
    }
  }

  if (riskScore >= 40) {
    return {
      allowed: true,
      discount: requestedDiscount,
      requireVerification: true,
      message: 'Your discount will be verified at checkout',
      riskScore,
      flags
    }
  }

  return {
    allowed: true,
    discount: requestedDiscount,
    requireVerification: false,
    message: `You qualify for ${requestedDiscount}% off!`,
    riskScore,
    flags
  }
}

Auto-Rotating Coupon Codes

Never use static codes that can be shared:

// lib/rotating-codes.ts
import crypto from 'crypto'

interface CouponConfig {
  rotationInterval: number  // milliseconds
  maxUses: number
  requireCardVerification: boolean
}

const SECRET = process.env.COUPON_SECRET!

function generateRotatingCode(
  countryCode: string,
  discount: number,
  sessionId: string,
  config: CouponConfig = { rotationInterval: 3600000, maxUses: 1, requireCardVerification: false }
): string {
  const timeSlot = Math.floor(Date.now() / config.rotationInterval)

  const payload = `${countryCode}:${discount}:${timeSlot}:${sessionId}`

  const signature = crypto
    .createHmac('sha256', SECRET)
    .update(payload)
    .digest('hex')
    .slice(0, 8)
    .toUpperCase()

  return `PPP-${countryCode}-${discount}-${signature}`
}

function validateCouponCode(
  code: string,
  expectedCountry: string,
  expectedDiscount: number,
  sessionId: string
): { valid: boolean; error?: string } {
  const parts = code.split('-')

  if (parts.length !== 4 || parts[0] !== 'PPP') {
    return { valid: false, error: 'Invalid coupon format' }
  }

  const [, countryCode, discountStr, providedSignature] = parts
  const discount = parseInt(discountStr)

  // Verify country matches
  if (countryCode !== expectedCountry) {
    return { valid: false, error: 'Coupon not valid for your country' }
  }

  // Verify discount matches
  if (discount !== expectedDiscount) {
    return { valid: false, error: 'Invalid discount amount' }
  }

  // Verify signature (check current and previous time slots)
  const currentTimeSlot = Math.floor(Date.now() / 3600000)

  for (let offset = 0; offset <= 1; offset++) {
    const timeSlot = currentTimeSlot - offset
    const payload = `${countryCode}:${discount}:${timeSlot}:${sessionId}`

    const expectedSignature = crypto
      .createHmac('sha256', SECRET)
      .update(payload)
      .digest('hex')
      .slice(0, 8)
      .toUpperCase()

    if (providedSignature === expectedSignature) {
      return { valid: true }
    }
  }

  return { valid: false, error: 'Coupon has expired' }
}

What NOT To Do

Don't Block All VPN Users

❌ Wrong:
if (isVPN) {
  return { blocked: true, message: "VPN users not allowed" }
}

✓ Right:
if (isVPN) {
  return {
    allowed: true,
    requireCardVerification: true,
    message: "Your discount will be verified at checkout"
  }
}

Why: Some legitimate users use VPNs for privacy or security. Blocking them entirely loses real customers.

Don't Over-Optimize for Fraud Prevention

Fraud rate: 0.1% → Perfect
Fraud rate: 1%   → Acceptable
Fraud rate: 5%   → Needs work
Fraud rate: 10%+ → Serious problem

But:
False positive rate: 5%+ → You're losing money

The math:

  • 1% fraud on $100 = $1 lost
  • 5% false positives on $50 average order = $2.50 lost
  • False positives cost more than fraud!

Don't Publicly Share Coupon Codes

❌ Static public code: "INDIA50"
❌ Predictable format: "PPP-{COUNTRY}-{DISCOUNT}"
❌ Posted on your pricing page

✓ Session-based unique codes
✓ Time-limited validity
✓ One-time use
✓ Requires signed session

SmartBanner's Approach

We implement all 6 layers automatically:

LayerSmartBannerDIY
IP Geolocation✅ Multiple providers🔧 Build yourself
VPN Detection✅ 99%+ accuracy🔧 API subscription
Tor Blocking✅ Real-time list🔧 Maintain list
Behavior Analysis✅ ML-powered🔧 Build rules
Fingerprinting✅ Built-in🔧 Complex to build
Card Verification✅ Stripe integration🔧 Webhook handling

Result: <0.1% fraud rate across all SmartBanner customers.

Monitoring Dashboard

Key Metrics to Track

const fraudMetrics = {
  // Real-time
  vpnDetectionRate: "% of requests flagged as VPN",
  cardMismatchRate: "% of payments with country mismatch",
  codeRejectionRate: "% of coupon codes rejected",

  // Daily
  fraudRate: "confirmed fraud / total regional sales",
  falsePositiveRate: "legitimate customers blocked",
  revenueProtected: "estimated fraud prevented",

  // Weekly
  topFraudCountries: "countries with highest fraud attempts",
  topFraudIPs: "IPs with multiple fraud attempts",
  newFraudPatterns: "emerging fraud techniques"
}

Alert Thresholds

MetricNormalWarningCritical
VPN detection rate5-10%>15%>25%
Card mismatch rate<1%>2%>5%
Code rejection rate<5%>10%>20%
Daily fraud rate<0.5%>1%>3%

Conclusion

Fraud protection isn't optional for regional pricing—it's essential. But with the right multi-layered approach, you can:

  • **Block 99%+ of fraud** without blocking legitimate customers
  • **Maintain <0.5% fraud rate** even with aggressive discounts
  • **Avoid false positives** that cost more than fraud itself

The key is balance: protect your revenue without creating friction for real customers.

Ready to implement bulletproof fraud protection? SmartBanner handles all 6 layers automatically, with <0.1% fraud rate and zero maintenance required. Start your free trial today.

SmartBanner includes everything you need

Stop building regional pricing from scratch. Get started in 2 minutes.

  • Location-based pricing for 195+ countries
  • VPN/proxy fraud protection
  • 50+ automated holiday campaigns
  • A/B testing for discount optimization
  • One-line JavaScript integration
Try SmartBanner Free

Stop leaving money on the table

Join 2,847+ SaaS founders who use SmartBanner to unlock international revenue. Setup takes 2 minutes. See results in days.

No credit card required. 14-day free trial on all paid plans.