Every checkout already collects two facts that should line up: the billing-address country, and the country IP geolocation puts the buyer in. Comparing them is one of the cheapest fraud checks you can run, and most checkouts never do.
By the end of this you'll have a pre-auth function in Node, Python, and PHP that reads the buyer's IP country and threat signals in one call, compares the country to the billing address, and returns allow, add friction, review, or block. It's one signal in a layered defense, not a silver bullet, and the article is honest about where it helps and where it doesn't.
TL;DR
- A buyer's IP country and billing country usually agree. A mismatch is a long-standing signal for stolen-card, card-not-present fraud.
- One call to an IP geolocation and security API returns
location.country_code2plus athreat_scorefrom 0 to 100 and flags for proxy, VPN, Tor, and residential proxy. - A mismatch on its own is weak. Travelers, expats, and corporate VPNs trip it constantly. Combine it with the threat and anonymizer signals before you act.
- Map the combination to a decision: allow, add friction (3DS), route to manual review, or block. Never hard-block on country alone.
- This catches third-party fraud, not friendly chargebacks, and it sits alongside AVS and 3-D Secure rather than replacing them.
- Working code in curl, Node, Python, and PHP, with timeouts, lookup-failure handling, and the
X-Forwarded-Forgotcha.
The check is cheap and fast: one lookup, a country comparison, and a small scoring function. The hard part isn't the code. It's tuning the thresholds so you flag real fraud without bouncing the customer who's flying home from a work trip.
What this check catches, and what it doesn't
A stolen card used from another country is the textbook case. The card details are real, the billing address is the cardholder's, but the person at the keyboard is somewhere else entirely. Their IP geolocates to one country while the billing address says another. That gap is the signal. Card-not-present is where card fraud concentrates, and by the Nilson Report's 2024 figures the US alone leads the world in dollars lost to CNP fraud.
What it does not catch is friendly fraud, also called first-party fraud, where the real cardholder makes the purchase and later disputes it. The cardholder’s own IP and billing address may match perfectly, because it is genuinely them. No IP check will flag that. If your chargebacks are mostly "item not received" disputes from real customers, this is the wrong tool and you want delivery proof and better dispute handling instead.
It's also not the same thing as two checks people confuse it with. AVS (Address Verification Service) asks whether the billing address the shopper typed matches the address the issuing bank has on file. Shipping-versus-billing mismatch asks whether the package is going somewhere other than the billing address. IP-country-versus-billing-country is a third, independent signal, and you can compute it yourself before you ever hit the card network. It complements AVS and 3-D Secure. It doesn't replace either.
The signal, and why a match isn't enough
Here's the part that trips people up. A country match does not mean the order is clean, and a country mismatch does not mean it's fraud.
Take a real flagged IP, 2.56.188.34. Geolocation puts it in the United States, in Dallas. If a fraudster routes through it and enters a US billing address, the countries match. A naive ip_country === billing_country check waves it straight through. But that IP is a proxy and a VPN exit with a threat_score of 80, and it's flagged as a known attacker. The country lined up. The IP was still poison.
That's why the country comparison is necessary but not sufficient. You read the country and the anonymity signals together, in one call, and you let the combination decide. A mismatch backed by a proxy or a high threat score is a real flag. A lone mismatch from a clean residential IP is probably someone traveling.
Get the country and threat data in one call
You want two things per order: the country the IP resolves to, and the risk signals for that IP. The IP geolocation endpoint returns both when you add include=security, so you don't pay for two round trips. I'll use ipgeolocation.io's IP Security API for the examples because the security object ships a threat_score, a dedicated is_residential_proxy flag, and provider names with confidence scores, which is more than the boolean-only services give you. The same approach works with any API that returns an IP country plus risk flags.
Trim the response to just the fields you need with fields:
curl -s "https://api.ipgeolocation.io/v3/ipgeo?apiKey=$IPGEO_API_KEY&ip=2.56.188.34&include=security&fields=location.country_code2,security"
The response for that IP, in full:
{
"ip": "2.56.188.34",
"location": {
"country_code2": "US"
},
"security": {
"threat_score": 80,
"is_tor": false,
"is_proxy": true,
"proxy_provider_names": ["Zyte Proxy"],
"proxy_confidence_score": 80,
"proxy_last_seen": "2025-12-12",
"is_residential_proxy": true,
"is_vpn": true,
"vpn_provider_names": ["Nord VPN"],
"vpn_confidence_score": 80,
"vpn_last_seen": "2026-01-19",
"is_relay": false,
"relay_provider_name": "",
"is_anonymous": true,
"is_known_attacker": true,
"is_bot": false,
"is_spam": false,
"is_cloud_provider": true,
"cloud_provider_name": "Packethub S.A."
}
}
location.country_code2 is the ISO 3166-1 alpha-2 code, the same two-letter format your checkout already stores for the billing country (US, GB, DE). That's what makes the comparison a one-liner. The security object is the rest of the picture: a 0 to 100 threat_score, boolean flags for each anonymity type, and, when there's a match, the provider name and a confidence score. proxy_provider_names of ["Zyte Proxy"] with proxy_confidence_score 80 is a lot more actionable than a bare "this is a proxy."
Cost note: the combined lookup costs 3 credits per IP (1 for the base lookup, 2 for security). If you ever need the risk signals without the location, there's a dedicated
/v3/securityendpoint that costs 2. For this check you want both, so the combined call is the right one. Private, bogon, and malformed IPs aren't billed at all.
Build the pre-auth check
The lookup is the same logic in every language: build the request, set a tight timeout, read country_code2 and security, and fail soft if anything goes wrong. A checkout can't hang on a slow network call, and it shouldn't crash an order because a fraud signal was briefly unreachable.
Node.js
// risk-lookup.js. Node 18+ has global fetch; on older Node, import node-fetch.
const IPGEO_API_KEY = process.env.IPGEO_API_KEY; // load from env, never hardcode a key
async function lookupRisk(ip) {
const url = new URL("https://api.ipgeolocation.io/v3/ipgeo");
url.searchParams.set("apiKey", IPGEO_API_KEY ?? "");
url.searchParams.set("ip", ip);
url.searchParams.set("include", "security"); // adds the security object (+2 credits)
url.searchParams.set("fields", "location.country_code2,security"); // keep the payload small
try {
// The API answers in well under 100ms; 1.5s is a generous ceiling before we give up.
const res = await fetch(url, { signal: AbortSignal.timeout(1500) });
if (!res.ok) throw new Error(`status ${res.status}`);
const data = await res.json();
return {
ipCountry: data?.location?.country_code2 ?? null, // ISO alpha-2, e.g. "US"
security: data?.security ?? null,
ok: true,
};
} catch (err) {
// Signal "unknown" so the caller can route the order to review instead of blocking a paying customer.
console.error(`IP risk lookup failed for ${ip}:`, err.message);
return { ipCountry: null, security: null, ok: false };
}
}
The function never throws. On a timeout or a bad status it returns ok: false, and the decision layer treats that as "couldn't check" rather than "block." That choice matters more than it looks, and the caching section comes back to it.
Python
import os
import requests # pip install requests
IPGEO_API_KEY = os.environ.get("IPGEO_API_KEY") # load from env, never hardcode a key
def lookup_risk(ip: str) -> dict:
params = {
"apiKey": IPGEO_API_KEY or "",
"ip": ip,
"include": "security", # adds the security object (+2 credits)
"fields": "location.country_code2,security", # keep the payload small
}
try:
# (connect, read) timeouts. A checkout cannot wait on a slow lookup.
resp = requests.get(
"https://api.ipgeolocation.io/v3/ipgeo",
params=params,
timeout=(1.0, 1.5),
)
resp.raise_for_status()
data = resp.json()
location = data.get("location") or {}
return {
"ip_country": location.get("country_code2"), # ISO alpha-2, e.g. "US"
"security": data.get("security"),
"ok": True,
}
except requests.RequestException as err:
print(f"IP risk lookup failed for {ip}: {err}")
return {"ip_country": None, "security": None, "ok": False}
PHP
<?php
// risk_lookup.php. Uses cURL, no dependencies.
function lookup_risk(string $ip): array {
$apiKey = getenv('IPGEO_API_KEY'); // load from env, never hardcode a key
$query = http_build_query([
'apiKey' => $apiKey ?: '',
'ip' => $ip,
'include' => 'security', // adds the security object (+2 credits)
'fields' => 'location.country_code2,security', // keep the payload small
]);
$ch = curl_init("https://api.ipgeolocation.io/v3/ipgeo?{$query}");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT_MS => 1500, // a checkout cannot wait on a slow lookup
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($body === false || $status !== 200) {
error_log("IP risk lookup failed for {$ip}: " . ($error ?: "status {$status}"));
return ['ip_country' => null, 'security' => null, 'ok' => false];
}
$data = json_decode($body, true);
if (!is_array($data)) {
error_log("IP risk lookup failed for {$ip}: invalid JSON response");
return ['ip_country' => null, 'security' => null, 'ok' => false];
}
return [
'ip_country' => $data['location']['country_code2'] ?? null, // ISO alpha-2, e.g. "US"
'security' => $data['security'] ?? null,
'ok' => true,
];
}
All three normalize failure the same way and guard every nested read (?., .get(), ??), because a partial response shouldn't throw a TypeError in the middle of an order.
Turn the signals into a decision
This is where the check earns its keep. The country comparison is one line; the judgment is the rest. It's plain logic with no network calls, so it stays the same across languages. Here it is in Node:
// decide-order.js
// ipgeolocation.io's documented threat_score bands:
// 1-19 low, 20-44 needs context, 45-79 elevated, 80-100 block or review.
function decideOrder({ ipCountry, security, billingCountry, ok }) {
const reasons = [];
// Lookup failed: don't reject a paying customer over outage. Route it to review.
if (!ok || !security) return { decision: "review", reasons: ["ip_risk_unavailable"] };
const score = security.threat_score ?? 0;
const billing = (billingCountry || "").toUpperCase(); // normalize to ISO alpha-2
const anonymized = Boolean(security.is_proxy || security.is_vpn || security.is_residential_proxy || security.is_tor);
const mismatch = Boolean(ipCountry && billing && ipCountry !== billing);
if (mismatch) reasons.push(`ip_${ipCountry}_vs_billing_${billing}`);
if (anonymized) reasons.push("anonymized_ip");
// Hard stop: a flagged attacker, or a top-band threat score (80-100).
if (security.is_known_attacker || score >= 80) {
reasons.push(security.is_known_attacker ? "known_attacker" : "threat_score_high");
return { decision: "block", reasons };
}
// Manual review: a mismatch backed by an anonymized IP, or an elevated score (45-79).
if (score >= 45) reasons.push("threat_score_elevated");
if ((mismatch && anonymized) || score >= 45) return { decision: "review", reasons };
// Light friction (e.g. a 3DS step-up): a lone mismatch, or a mid-band score (20-44).
if (score >= 20) reasons.push("threat_score_needs_context");
if (mismatch || score >= 20) return { decision: "friction", reasons };
return { decision: "allow", reasons };
}
The reasons array is the part you'll thank yourself for later. When an order lands in review, you want to know it was ["ip_GB_vs_billing_US", "anonymized_ip"] and not guess. Log it with the order.
The same logic in Python:
# decide_order.py. Pure logic, no network calls.
# Same threat_score bands: 1-19 low, 20-44 needs context, 45-79 elevated, 80-100 block or review.
def decide_order(ip_country, security, billing_country, ok):
# Lookup failed: don't reject a paying customer over our outage. Fail open to a soft check.
if not ok or not security:
return {"decision": "review", "reasons": ["ip_risk_unavailable"]}
reasons = []
score = security.get("threat_score") or 0
billing = (billing_country or "").upper() # normalize to ISO alpha-2
anonymized = bool(
security.get("is_proxy") or security.get("is_vpn")
or security.get("is_residential_proxy") or security.get("is_tor")
)
mismatch = bool(ip_country and billing and ip_country != billing)
if mismatch:
reasons.append(f"ip_{ip_country}_vs_billing_{billing}")
if anonymized:
reasons.append("anonymized_ip")
# Hard stop: a flagged attacker, or a top-band threat score (80-100).
if security.get("is_known_attacker") or score >= 80:
reasons.append("known_attacker" if security.get("is_known_attacker") else "threat_score_high")
return {"decision": "block", "reasons": reasons}
# Manual review: a mismatch backed by an anonymized IP, or an elevated score (45-79).
if (score >= 45):
reasons.append("threat_score_elevated");
if (mismatch and anonymized) or score >= 45:
return {"decision": "review", "reasons": reasons}
# Light friction (e.g. a 3DS step-up): a lone mismatch, or a mid-band score (20-44).
if (score >= 20):
reasons.append("threat_score_needs_context");
if mismatch or score >= 20:
return {"decision": "friction", "reasons": reasons}
return {"decision": "allow", "reasons": reasons}
And in PHP:
<?php
// decide_order.php. Pure logic, no network calls.
// Same threat_score bands: 1-19 low, 20-44 needs context, 45-79 elevated, 80-100 block or review.
function decide_order(?string $ipCountry, ?array $security, ?string $billingCountry, bool $ok): array {
// Lookup failed: don't reject a paying customer over our outage. Fail open to a soft check.
if (!$ok || !$security) {
return ['decision' => 'review', 'reasons' => ['ip_risk_unavailable']];
}
$reasons = [];
$score = $security['threat_score'] ?? 0;
$billing = strtoupper($billingCountry ?? ''); // normalize to ISO alpha-2
// empty() treats a missing key or a false flag the same way: not anonymized.
$anonymized = !empty($security['is_proxy']) || !empty($security['is_vpn'])
|| !empty($security['is_residential_proxy']) || !empty($security['is_tor']);
$mismatch = $ipCountry && $billing && $ipCountry !== $billing;
if ($mismatch) {
$reasons[] = "ip_{$ipCountry}_vs_billing_{$billing}";
}
if ($anonymized) {
$reasons[] = 'anonymized_ip';
}
// Hard stop: a flagged attacker, or a top-band threat score (80-100).
if (!empty($security['is_known_attacker']) || $score >= 80) {
$reasons[] = !empty($security['is_known_attacker']) ? 'known_attacker' : 'threat_score_high';
return ['decision' => 'block', 'reasons' => $reasons];
}
// Manual review: a mismatch backed by an anonymized IP, or an elevated score (45-79).
if ( $score >= 45) {
$reasons[] = 'threat_score_elevated';
}
if (($mismatch && $anonymized) || $score >= 45) {
return ['decision' => 'review', 'reasons' => $reasons];
}
// Light friction (e.g. a 3DS step-up): a lone mismatch, or a mid-band score (20-44).
if ( $score >= 20) {
$reasons[] = 'threat_score_needs_context';
}
if ($mismatch || $score >= 20) {
return ['decision' => 'friction', 'reasons' => $reasons];
}
return ['decision' => 'allow', 'reasons' => $reasons];
}
All three return the same decision and reasons for the same inputs, so the walkthrough below applies whichever one you run.
Walk it through with real data. An order with a GB billing address coming from 2.56.188.34 (US, threat_score 80, known attacker) blocks on the attacker flag, and the country mismatch is right there in the reasons. Now flip the billing country to US: the countries match, but it still blocks, because the threat score and attacker flag don't care about the address. That's the case a country-only check would have shipped. A genuine order from a Swedish customer on a Swedish ISP (91.128.103.196, country SE, clean signals) with an SE billing address sails through to allow.
Here's the same logic as a table you can tune to your own chargeback rate:
| Signals | Decision |
|---|---|
Known attacker, or threat_score 80-100 |
Block |
Country mismatch with an anonymized IP (proxy, VPN, residential proxy, Tor), or threat_score 45-79 |
Manual review |
Country mismatch alone, or threat_score 20-44 |
Friction (3DS step-up) |
Country match, threat_score 0-19, no flags |
Allow |
If you run 3-D Secure, "friction" and "review" are where you trigger it. The liability shift on a 3DS-authenticated transaction is worth far more than the few seconds it adds for a risky order.
Getting the real client IP
The whole check depends on reading the buyer's actual IP, and behind a load balancer or a CDN that's the part people get wrong. The socket address is your proxy, not the customer. The real IP is in X-Forwarded-For, but you can't trust that header blindly, because anyone can send it.
// Express: tell the framework how many proxies sit in front of you, then read req.ip.
app.set("trust proxy", 1); // 1 = one trusted hop (your LB). Set this to your real topology.
app.post("/checkout", async (req, res) => {
const clientIp = req.ip; // resolved from X-Forwarded-For using the trust setting above
// Never read req.headers["x-forwarded-for"] and grab the first value yourself.
// A client can forge that header; trust only the hop your infrastructure controls.
// ... call lookupRisk(clientIp) and decideOrder(...) here
});
Set trust proxy to match what's actually in front of your app. Trusting too many hops lets a client spoof their way to any country they like, which quietly turns your fraud check into theater.
Handling false positives
A country mismatch is noisy, and if you block on it you'll punish good customers. The most common cases, all legitimate:
- A customer in Berlin who hits your checkout through their employer's VPN, which exits in Frankfurt or even another country. Clean card, real person, mismatched IP.
- Anyone traveling. A US cardholder buying from a hotel in Tokyo trips the mismatch every time.
- Expats and dual-residents whose card was issued in one country while they live in another.
- iCloud Private Relay and similar privacy relays. The security object flags these as
is_relay, separate fromis_vpn, and that distinction matters. A Private Relay user is an ordinary Apple customer, not a fraudster, so don't treat a relay like a proxy.
This is why the scoring leans on the threat signals, not the mismatch alone. The Berlin customer's corporate VPN usually carries a low threat score and no attacker flag, so they land in friction at worst, not a block. If you serve a corridor of legitimate cross-border traffic (say, a lot of real orders where the IP and billing country differ for a known reason), keep an allowlist and skip the friction for it. Tune to your numbers: if your manual-review queue fills with obvious good orders, your thresholds are too tight.
When the lookup is slow or down, and caching
Two operational decisions make or break this in production.
First, if the lookup times out or errors, the code above returns ok: false, and decideOrder sends the order to review rather than blocking it. Blocking real revenue because a third-party API had a bad minute is the wrong trade for most stores. If you're in a high-fraud category and would rather hold than risk it, flip that branch to fail closed, but make it a deliberate choice.
Second, cache by IP. The same buyer often retries checkout two or three times, and that's the same IP each time. A short cache (5 to 15 minutes, keyed on the IP) cuts both latency and credits, since each lookup costs 3 credits. Redis is the obvious home for it; an in-process LRU works for a single instance.
// Tiny in-memory cache with a TTL. Use Redis across multiple instances.
const cache = new Map();
const TTL_MS = 10 * 60 * 1000; // 10 minutes
async function lookupRiskCached(ip) {
const hit = cache.get(ip);
if (hit && Date.now() - hit.at < TTL_MS) return hit.value; // reuse a recent result
const value = await lookupRisk(ip);
if (value.ok) cache.set(ip, { value, at: Date.now() }); // only cache successful lookups
return value;
}
Note the guard: only successful results get cached. Caching an ok: false would pin a failed lookup for ten minutes and quietly degrade every retry in that window.
A few extra notes
IPv6 works the same way. The response shape is identical, and country_code2 comes back for v6 addresses too, so the code above doesn't change.
Decide on country, not city. Country-level geolocation is the dependable layer. City and below get fuzzier, especially on mobile carriers and recently reallocated ranges, so a city mismatch is too unreliable to gate a payment on.
You're not married to one provider. MaxMind GeoIP2 and its minFraud product, IPinfo, IP2Location, and IPLocate all return an IP country, and most return some VPN or proxy signal. Stripe Radar bundles a version of this if you're already on Stripe. Pick by what's in your stack, what the free tier returns, and whether you need residential-proxy detection, which is the signal most of the cheaper options skip. The IP Security API used here is on every paid plan starting at $19 a month, so it's not a separate enterprise add-on.
And keep this in proportion. The IP-country check is a cheap pre-auth signal that sits in front of AVS and 3-D Secure, not a replacement for either. It catches a real class of stolen-card fraud before the order ships. It will not catch a cardholder who disputes their own legitimate purchase.
Drop lookupRiskCached and decideOrder into your order pipeline behind whatever checks you already run, log the reasons on every non-allow decision, and watch your review queue for a week before you tighten anything. If I were starting fresh, I'd wire 3DS to the friction and review outcomes from day one, rather than bolting it on after the first wave of mismatched-country chargebacks.

Top comments (0)