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
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.
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/yearHow 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 RedditWhere Fraudsters Share Codes
| Platform | Risk Level | How Codes Spread |
|---|---|---|
| Reddit r/deals | Very High | Screenshot posts |
| Twitter/X | High | Viral threads |
| Discord | High | Private servers |
| Telegram | Very High | Deal channels |
| Slickdeals | High | Forum posts |
| Personal blogs | Medium | "Life hacks" articles |
Ready to increase your international revenue?
Start your free trial and see results in days, not months.
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
}
}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]
}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 moneyThe 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 sessionSmartBanner's Approach
We implement all 6 layers automatically:
| Layer | SmartBanner | DIY |
|---|---|---|
| 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
| Metric | Normal | Warning | Critical |
|---|---|---|---|
| VPN detection rate | 5-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
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.