VPN detection means checking whether a visitor’s IP address belongs to a VPN server, an open proxy, a Tor exit node, a privacy relay or a data center, and using the answer as one signal in a risk decision. You don’t need to maintain the lists yourself: an IP security API returns all flags and a threat score in one request. This guide explains how detection works, what the flags mean, and how to add it to sign-up and checkout in Node.js and Python without locking out people who use a VPN for good reasons.
How VPN detection works
An address is flagged when one or more of these sources list it:
| Signal | What it catches | Reliability |
|---|---|---|
| VPN server lists | Exit servers of commercial and consumer VPN services | High for commercial VPNs; they run on known server ranges |
| Open proxy lists | Public HTTP and SOCKS proxies | High while the proxy is listed; proxies come and go quickly |
| Tor exit lists | Tor exit nodes, published by the Tor network itself | Very high |
| Privacy relays | iCloud Private Relay egress addresses, published by Apple | Very high, and the users are ordinary Safari users |
| Data center ranges | Cloud and hosting providers (see the cloud IP ranges) | High; real users rarely browse from AWS or DigitalOcean |
| Threat feeds | Addresses seen in attacks, spam, brute force or botnets | Good for recent activity; addresses get reassigned |
What detection cannot see well: residential proxies, which route traffic through real home connections (what a residential proxy is), self-hosted VPNs on a home or office line, and mobile networks where thousands of users share one address behind carrier-grade NAT. Treat a clean result as “no known signal”, not as proof.
What an IP security API returns
The ipbase info endpoint returns a security object next to the location. These are the fields to build rules on:
| Field | Meaning |
|---|---|
is_vpn | Server of a commercial or consumer VPN service |
is_proxy | Public or open proxy (HTTP, SOCKS) |
is_tor | Tor exit node |
is_icloud_relay | iCloud Private Relay egress address |
is_datacenter | Address of a data center, cloud, hosting or CDN network |
is_anonymous | Any of Tor, VPN, open proxy or privacy relay |
proxy_type | tor, vpn, proxy, relay or an empty string |
is_abuser, is_known_attacker, is_spam | Recently reported for abuse, attacks or spam |
is_bot | Verified crawler or monitor (for example Googlebot) or a research scanner |
threat_score | 0 (no known threat) to 100 |
is_corporate_gateway | Egress of a corporate security gateway such as Zscaler, which VPN lists often list too |
vpn.provider, vpn.last_seen, vpn.confidence | Which VPN service, when the lists last saw the address, how strongly they back the flag |
reasons | One short sentence per flag, such as Tor exit node, seen 2026-10-16 |
The documentation gives examples for the threat score: a data center address alone scores 10, a VPN server 30, a VPN server in a data center 40, a Tor exit node 60, and a Tor exit node that is also reported for attacks 90. Verified crawlers and allowlisted infrastructure such as public DNS resolvers score 0.
The security data is part of the Medium plan and above. With a free key the security object is still in the response, but every field in it is null. Code that only checks if (security.is_vpn) then lets everyone through without noticing, so the examples below check for null explicitly.
Step 1: check an address
Try a single address with the free VPN and proxy checker, or with curl and your key:
curl -G https://api.ipbase.com/v2/info \
-d ip=203.0.113.50 \
-d fields=ip,security \
-H "apikey: $IPBASE_KEY"
For a Tor exit node the answer looks like this (abridged):
{
"data": {
"ip": "203.0.113.50",
"security": {
"is_anonymous": true,
"is_datacenter": false,
"is_vpn": false,
"is_proxy": false,
"is_tor": true,
"proxy_type": "tor",
"is_icloud_relay": false,
"threat_score": 60,
"tor": { "last_seen": "2026-10-16", "confidence": 0.95 },
"is_corporate_gateway": false,
"reasons": ["Tor exit node, seen 2026-10-16"]
}
}
}
The confidence values rank how strongly the lists back a flag; the documentation says they are hand-weighted and not a calibrated probability.
Step 2: decide what each signal means for you
A flag says something about the network, not about the person. Match the response to the risk of the action: a newsletter sign-up needs less than a payout or a free trial with credits.
| Signal | Typical response |
|---|---|
| Tor exit node | Block or challenge sensitive actions (payments, payouts, trials) |
| Known attacker | Block, or require strong verification |
| Commercial VPN or open proxy | Ask for email or phone verification; allow browsing |
| Data center | Likely automated: CAPTCHA, rate limits, verification |
| Abuse or spam reports, threat score 50 and above | Verification, closer review |
| iCloud Private Relay | Allow; it is a mainstream Apple feature |
| Corporate security gateway | Allow; it is office traffic, even when VPN lists include it |
| No flags | Allow; it is not proof of a residential user |
If your product is licensed by country, combine the flags with the country: a streaming or betting service blocks VPN traffic because the location cannot be trusted, not because the user is malicious. The geo blocking guide shows the country side.
Step 3: add VPN detection to sign-up
The function asks only for the security object, turns the flags into one of four actions and keeps the reason for your logs. It fails open: if the lookup fails or times out, the user can sign up, so an outage on either side never blocks your funnel.
// risk.mjs – Node.js 18 or later
const API = "https://api.ipbase.com/v2/info";
export async function riskCheck(ip) {
let s;
try {
const res = await fetch(`${API}?${new URLSearchParams({ ip, fields: "security" })}`, {
headers: { apikey: process.env.IPBASE_KEY },
signal: AbortSignal.timeout(3000),
});
if (!res.ok) return { action: "allow", reason: `lookup failed (${res.status})` };
s = (await res.json()).data.security;
} catch (err) {
return { action: "allow", reason: `lookup failed (${err.name})` }; // fail open
}
// A plan without security data gets every flag as null: that is not "clean"
if (s.threat_score === null) return { action: "unknown", reason: "no security data in this plan" };
if (s.is_tor) return { action: "block", reason: "Tor exit node" };
if (s.is_known_attacker) return { action: "block", reason: "known attacker" };
if (s.is_corporate_gateway) return { action: "allow", reason: "corporate security gateway" };
if (s.is_icloud_relay) return { action: "allow", reason: "iCloud Private Relay" };
if (s.is_vpn || s.is_proxy) return { action: "verify", reason: s.is_vpn ? "VPN" : "proxy" };
if (s.is_datacenter) return { action: "verify", reason: "data center" };
if (s.is_abuser || s.is_spam || s.threat_score >= 50) return { action: "verify", reason: `threat score ${s.threat_score}` };
return { action: "allow", reason: "no flags" };
}
The order matters: Tor and known attackers first, then the two allow cases (a corporate gateway can also carry is_vpn), then the signals that call for verification. In an Express route behind one proxy:
// signup.mjs – Express 5
import express from "express";
import { riskCheck } from "./risk.mjs";
const app = express();
app.set("trust proxy", 1);
app.use(express.json());
app.post("/signup", async (req, res) => {
const risk = await riskCheck(req.ip);
console.log("signup risk", req.ip, risk); // keep the reason for support and tuning
if (risk.action === "block") {
return res.status(403).json({ error: "Sign-ups from this network are not possible. Please contact support." });
}
// "verify" and "unknown": create the account, but confirm email or phone first
const needsVerification = risk.action !== "allow";
res.status(201).json({ email: req.body.email, needsVerification });
});
app.listen(3000);
We ran both files with Node.js 26 and Express 5 against a local stub of the API: a Tor exit got a 403, a VPN address an account with needsVerification: true, a clean address one without, a free key unknown, and an invalid key or an unreachable API allow with the reason logged. Read req.ip only after you set trust proxy for your setup; the IP geolocation tutorial explains how to find the real client IP.
Step 4: check existing accounts in bulk
To review accounts you already have, send their last login addresses to the batch endpoint: up to 1,000 addresses per POST with an API key, each valid address counting as one request.
# audit.py – flag existing accounts by their last login IP, 1,000 addresses per request
import os
import requests
FIELDS = "ip,security.is_vpn,security.is_proxy,security.is_tor,security.is_datacenter,security.threat_score"
def flagged(ips: list[str]) -> dict[str, dict]:
unique = list(dict.fromkeys(ips))
found: dict[str, dict] = {}
with requests.Session() as session:
session.headers["apikey"] = os.environ["IPBASE_KEY"]
for start in range(0, len(unique), 1000):
res = session.post(
"https://api.ipbase.com/v2/batch",
params={"fields": FIELDS},
json={"ips": unique[start : start + 1000]},
timeout=60,
)
res.raise_for_status()
for ip, row in res.json()["data"].items():
s = row.get("security") or {}
if s.get("threat_score") is None:
raise RuntimeError("this API key's plan has no security data")
if s["is_vpn"] or s["is_proxy"] or s["is_tor"] or s["is_datacenter"]:
found[ip] = s
return found
With a free key the function stops with an error instead of reporting that no account is flagged. We ran it with Python 3.15 and requests against the stub.
Avoiding false positives
- Verify instead of block wherever you can. An email or SMS code costs a real user seconds; a block loses them.
- Allow iCloud Private Relay and corporate gateways (
is_corporate_gateway). Both are everyday traffic. - Look at
last_seen. An address reported months ago may have a new owner today. - Don’t treat mobile addresses as suspicious because many sign-ups share one. Carrier-grade NAT puts whole neighbourhoods behind a few addresses.
- Log the reason with each decision, so support can explain a block and you can see which rule costs you real customers.
Privacy
IP addresses are personal data under the GDPR. Fraud prevention is a common reason to process them, but say in your privacy policy that you check sign-up and payment addresses with an external service, and keep only the decision and the reason, not the full lookup. ipbase’s sub-processors and trust center list how the data is handled on our side.