IP 欺诈检测 API:在 Node 和 Python 中为注册、登录和结账打分
为注册、登录和结账接入 IP 欺诈检测 API:我们在六个公共 IP 上运行过的 Node 和 Python 代码,附真实输出、缓存方法和速率限制。

两个注册请求相隔一秒到达,两者都隐藏了用户的家庭地址。一个来自汉堡的 Tor 出口。另一个来自 iCloud Private Relay,Apple 将其描述为“iCloud+ 订阅的一部分”,在“您使用 Safari 浏览网页时”保护您(Apple 支持)。一条拦截“所有隐藏来源”的规则会把两者都拒绝,而其中一个是付费的 Apple 客户。
区分这两者,就是这项工作的目的。这只是 IP 情报所涵盖内容中的一部分,而且只需一个不到 50 行的文件即可实现。下面您将看到请求、一个真实响应、带缓存和故障处理的 Node 与 Python 版本、它们对六个地址的输出,以及这项检查的不足之处。

IP 欺诈检测 API 只需要一次请求
整个集成就是一次 GET 请求,IP 放在路径中,密钥放在 x-api-key 请求头中。API 参考文档记录的也是同样的形式:
curl https://api.geoiphub.com/v1/lookup/45.83.220.7 \
-H "x-api-key: YOUR_API_KEY"
第一次尝试时,有三个细节容易让人出错:
- IP 放在路径中。
/v1/lookup/{ip}是唯一的形式。使用查询字符串的版本会被重定向,而不会返回数据。 - 不要发送占位符。 我们发送了字面字符串
YOUR_API_KEY,收到了401和{"message":"Invalid API key"}。在拿到真正的密钥之前,请不要带这个请求头;匿名调用每分钟可以进行 30 次。 - 每次调用一个 IP。 每个请求只接受一个地址,因此筛查一个列表的脚本会为每个地址发起一次请求,每次都计入您的限额。
当您结束测试阶段时,在控制台中创建一个免费密钥。免费套餐为该密钥提供每天 1,500 次查询,包含所有字段。
一个 Mullvad 地址返回了什么,以及应根据哪些字段进行分支判断
响应是大约 2.7 KB、分为十个组的嵌套 JSON,而欺诈检查只需要其中两个:detection 和 scoring。下面是对 45.83.220.7 的查询结果,经过精简,但数值未作改动:
{
"ip": "45.83.220.7",
"asn": {
"asn": 39351,
"org": "ESAB-AS - 31173 Services AB",
"asn_type": "hosting",
"connection_type": "datacenter"
},
"geo": {
"country_code": "SE",
"city": "Malmö"
},
"detection": {
"is_vpn": true,
"is_proxy": false,
"is_tor": false,
"is_hosting": true,
"is_relay": false,
"vpn_provider": "Mullvad (31173 Services)",
"vpn_confidence": 85
},
"scoring": {
"fraud_score": 46,
"confidence": 0.56,
"recommended_action": "review",
"detection_methods": [
"known_vpn_server",
"datacenter_ip"
]
},
"meta": {
"last_classified_at": "2026-10-16T07:40:17.524Z"
}
}
该地址位于 31173 Services 上,也就是 Mullvad VPN 在马尔默运行其服务器的网络。我们在 10 月 13 日检查时,它并不在 Mullvad 自己公布的服务器列表中。有两个信号被触发。known_vpn_server 来自我们的已知 VPN 托管网络表,这就是为什么 vpn_confidence 是 85,而不是与 Mullvad 自有服务器列表匹配时给出的 95。datacenter_ip 表示它是托管地址,而不是家庭连接。两者合计评分为 46,落在 review 区间:单单一个 VPN 并不等于欺诈。
有三个字段承载着决策:
| 字段 | 内容 | 代码如何使用 |
|---|---|---|
scoring.recommended_action | allow(评分不超过 25)、review(不超过 50)、step_up(不超过 75)、block(高于 75) | 分支依据 |
scoring.fraud_score | 该操作背后的 0 到 100 综合评分 | 用于日志记录和后续调优 |
scoring.detection_methods | 被触发的信号,例如 tor_exit 或 openvpn_open | 您存储并展示给审核人员的原因 |
detection 标记(is_vpn、is_proxy、is_tor、is_hosting、is_relay)是评分背后的原始事实。在您的业务与默认区间不同的少数规则中使用它们。评分也包含减分信号:住宅 ISP 减 20 分,已验证的爬虫减 30 分,因此单个标记并不会把地址推入 block。IP 欺诈评分指南解释了哪些因素会影响这个数字。
Node 版本:fetch、一小时缓存和四个分支
这就是我们运行的原始文件,运行于 Node 24,无任何依赖。内置的 fetch 需要 Node 18 或更高版本:
// screen.mjs: score an IP before a signup, login or checkout. Node 18+, no dependencies.
const API = "https://api.geoiphub.com/v1/lookup/";
const KEY = process.env.GEOIPHUB_API_KEY; // without a key: 30 lookups a minute, for testing only
const TTL_MS = 60 * 60 * 1000; // reuse a verdict for an hour
const cache = new Map(); // ip -> { at, data }
async function lookup(ip) {
const hit = cache.get(ip);
if (hit && Date.now() - hit.at < TTL_MS) return hit.data;
const headers = KEY ? { "x-api-key": KEY } : {};
const res = await fetch(API + encodeURIComponent(ip), {
headers,
signal: AbortSignal.timeout(3000),
});
if (res.status === 429) throw new Error(`rate limited, retry after ${res.headers.get("retry-after")}s`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
cache.set(ip, { at: Date.now(), data });
return data;
}
export async function screen(ip, flow) {
let data;
try {
data = await lookup(ip);
} catch (err) {
return { action: "review", reason: `lookup failed: ${err.message}` }; // fail soft, never block on our outage
}
const { scoring, detection } = data;
let action = scoring.recommended_action; // "allow" | "review" | "step_up" | "block"
if (detection.is_tor) action = "block"; // no step-up through a Tor exit
if (flow === "checkout" && action === "review") action = "step_up"; // stricter where money moves
const flags = ["is_vpn", "is_proxy", "is_tor", "is_hosting", "is_relay"].filter((f) => detection[f]);
return { action, score: scoring.fraud_score, flags, methods: scoring.detection_methods };
}
// Try it: node screen.mjs signup 45.33.32.156 104.28.46.99
const [flow, ...ips] = process.argv.slice(2);
for (const ip of ips) {
const v = await screen(ip, flow);
console.log(ip.padEnd(15), v.action.padEnd(8), v.score ?? "-", v.flags?.join(",") || "-", v.methods?.join(",") || v.reason);
}
其中有四个决定是有意为之的:
- 它以
recommended_action为起点,只在两处覆盖它。Tor 出口始终拦截;在结账环节,review会变为step_up,因为那是资金流出的地方。 - 它采用软失败。 超时、5xx 或 429 返回
review,而不是block。如果我们的 API 无法访问,您的注册流程仍会照常运行,并被记录下来以便复查。 - 超时时间为 3 秒。 在我们这边,已知 IP 由内存映射数据库在不到一毫秒内作答,因此大部分等待时间是网络往返。我们从未见过的 IP 会被实时分类,耗时不到 2.5 秒,3 秒足以覆盖这种情况。
- 密钥保留在您的服务器上。 从后端调用 API,绝不要在浏览器代码中调用,否则任何人都能读取密钥。
Python 版本需要设置 User-Agent,否则会收到 403
我们的 API 对 Python urllib 的默认 User-Agent(Python-urllib/3.x)返回 403 Forbidden。我们在 10 月 16 日 05:36 UTC 用一个最简单的 urllib.request.urlopen 调用复现了这一点:HTTP Error 403: Forbidden。curl 和 requests 不会遇到这个问题,因为它们发送的 User-Agent 不同。所以 Python 文件设置了自己的 User-Agent,并附带注释说明原因:
# screen.py: score an IP before a signup, login or checkout. Python 3.8+, standard library only.
import json, os, sys, time, urllib.error, urllib.request
API = "https://api.geoiphub.com/v1/lookup/"
KEY = os.environ.get("GEOIPHUB_API_KEY") # without a key: 30 lookups a minute, for testing only
TTL = 3600 # reuse a verdict for an hour
_cache = {} # ip -> (time, data)
def lookup(ip):
hit = _cache.get(ip)
if hit and time.time() - hit[0] < TTL:
return hit[1]
# Set a User-Agent: our API answers urllib's default "Python-urllib/3.x" with 403 Forbidden.
headers = {"User-Agent": "signup-screen/1.0"}
if KEY:
headers["x-api-key"] = KEY
req = urllib.request.Request(API + ip, headers=headers)
with urllib.request.urlopen(req, timeout=3) as resp:
data = json.load(resp)
_cache[ip] = (time.time(), data)
return data
def screen(ip, flow):
try:
data = lookup(ip)
except urllib.error.HTTPError as e:
wait = e.headers.get("Retry-After") if e.code == 429 else None
return {"action": "review", "reason": f"HTTP {e.code}" + (f", retry after {wait}s" if wait else "")}
except (OSError, ValueError) as e: # network error, timeout, bad JSON
return {"action": "review", "reason": f"lookup failed: {e}"} # fail soft
scoring, detection = data["scoring"], data["detection"]
action = scoring["recommended_action"] # "allow" | "review" | "step_up" | "block"
if detection["is_tor"]:
action = "block" # no step-up through a Tor exit
if flow == "checkout" and action == "review":
action = "step_up" # stricter where money moves
flags = [f for f in ("is_vpn", "is_proxy", "is_tor", "is_hosting", "is_relay") if detection[f]]
return {"action": action, "score": scoring["fraud_score"], "flags": flags,
"methods": scoring["detection_methods"]}
if __name__ == "__main__":
flow, ips = sys.argv[1], sys.argv[2:]
for ip in ips:
v = screen(ip, flow)
print(f"{ip:<15} {v['action']:<8}", v.get("score", "-"), ",".join(v.get("flags", [])) or "-",
",".join(v.get("methods", [])) or v.get("reason", ""))
它与 Node 文件的逻辑相同,只使用标准库,在 Python 3.9.6 上测试过。urllib 对任何非 2xx 状态都会抛出 HTTPError,因此 429 及其 Retry-After 请求头是从异常中读取的。
两个脚本对六个公共地址的输出
两个脚本对每个地址都给出了相同的结论。我们在 2026 年 10 月 16 日 07:39 UTC 运行了 Node 文件,07:40 UTC 运行了 Python 文件,均未使用密钥,针对六个公共地址。最后一条命令将 Mullvad 地址作为结账场景重新运行:
node screen.mjs signup 45.33.32.156 104.28.46.99 45.83.220.7 91.90.122.25 102.129.235.17 185.220.101.1
node screen.mjs checkout 45.83.220.7
45.33.32.156 allow 10 is_hosting datacenter_ip
104.28.46.99 allow 9 is_relay relay_ip
45.83.220.7 review 46 is_vpn,is_hosting known_vpn_server,datacenter_ip
91.90.122.25 step_up 64 is_hosting datacenter_blocklisted,no_ptr_datacenter,known_spammer
102.129.235.17 block 92 is_vpn known_vpn_server,openvpn_open,ipsec_open
185.220.101.1 block 100 is_tor,is_hosting tor_exit,datacenter_blocklisted,no_ptr_datacenter,known_spammer
45.83.220.7 step_up 46 is_vpn,is_hosting known_vpn_server,datacenter_ip
使用相同参数运行 python3 screen.py,打印出的七行内容逐字符完全相同。下面是每个地址的身份以及它落在相应区间的原因:
| 地址 | 身份 | 操作 | 触发的信号 |
|---|---|---|---|
45.33.32.156 | scanme.nmap.org,Nmap 在 Linode 上的公共测试主机 | allow,10 | 仅 datacenter_ip。单单托管地址并不等于欺诈 |
104.28.46.99 | Cloudflare 上的 iCloud Private Relay 出口 | allow,9 | relay_ip。评分将中继地址的上限设为 60,因此它们永远不会达到 block |
45.83.220.7 | Mullvad 在马尔默的网络 | review,46;结账时为 step_up | known_vpn_server、datacenter_ip |
91.90.122.25 | 位于列支敦士登沙恩、托管在 M247 上的 PIA VPN 服务器 | step_up,64 | datacenter_blocklisted、no_ptr_datacenter、known_spammer。未被标记为 VPN |
102.129.235.17 | 位于华盛顿特区的商业 VPN 服务器 | block,92 | known_vpn_server,以及 openvpn_open 和 ipsec_open:我们的探测完成了 OpenVPN 和 IPsec 握手 |
185.220.101.1 | 汉堡的 Tor 出口 | block,100 | tor_exit、datacenter_blocklisted、no_ptr_datacenter、known_spammer |
开头提到的两个注册请求落在了两个极端:Tor 出口为 100,Private Relay 用户为 9。正是这种差距,说明了应该打分,而不是拦截所有隐藏来源的地址。
结果会随着我们的数据变化而变化。在本次重写之前,本文显示 45.83.220.7 是普通托管地址,评分为 10,结果为 allow。到 10 月 8 日,我们的扫描器已将其关联到 Mullvad,现在它的评分为 46。请在每次决策时记录操作和方法,这样类似的变化就会体现在您的数据中,而不会让您措手不及。
在哪里调用:每次注册、登录和结账
在服务器端,在提交操作之前,对每次注册、每次登录和每次结账进行评分。这是欺诈者能有所收获的三个时刻,而且每个用户发生这些操作的次数很少,足以放进每日额度。不要在每次页面浏览时都调用它。
每种操作在实践中的含义:
| 操作 | 注册 | 登录 | 结账 |
|---|---|---|---|
allow | 创建账户 | 登录 | 收款 |
review | 创建账户,标记待审核 | 登录,记录原因 | 我们的代码将其提升为 step_up |
step_up | 先验证邮箱或手机 | 要求第二重验证因素 | 运行 3-D Secure |
block | 以中性措辞拒绝 | 拒绝,并计入锁定次数 | 拒绝订单 |
默认区间只是一个起点,您应根据自己的欺诈数据调整它们。我们的登录、注册和结账的欺诈评分阈值给出了每种操作的初始阈值以及各自的理由。
还要确保对正确的地址打分。在负载均衡器或 CDN 后面,套接字地址是您的代理,而客户端的 IP 位于转发请求头中,任何客户端也都可以伪造它。请从您自己的受信任代理添加的那一跳中获取,绝不要使用客户端发送的最左侧值。
保持在每天 1,500 次查询以内:缓存与速率限制
免费套餐每天允许 1,500 次查询。在您自己的代码中按 IP 缓存,额度就能用得更久,因为同一个人通常会在一小时内从同一地址注册、登录和付款。
API 本身无助于缓存:每个响应都带有 Cache-Control: private, no-store,因此任何共享 HTTP 缓存都不会保存它。两个脚本都在进程内存中将结论保留一小时。如果您运行多台服务器,请使用 Redis 或您现有的缓存。不要将结论保留超过一天,因为我们的威胁数据源和查询数据库每天都会刷新。
这两种限制的行为不同,我们在测试本文时就触发了其中一种:
| 调用方式 | 限制 | 超出后会发生什么 |
|---|---|---|
| 无密钥 | 每个调用方 IP 每分钟 30 次查询 | 一分钟内的第 30 个请求返回 429,并将该 IP 封禁两小时 |
| 免费密钥 | 每天 1,500 次查询 | 返回 429 和 Retry-After 请求头,直到每日计数器重置 |
10 月 16 日 05:37 UTC,我们发起了一批匿名请求,想看看 429 的样子。不到二十秒,API 就返回了 429 和 Retry-After: 7199,来自我们 IP 的所有匿名调用都被封禁了两小时,包括我们自己的脚本。它们的软失败路径就是我们得到的第一份真实输出:
45.33.32.156 review - - lookup failed: rate limited, retry after 6971s
45.33.32.156 review - - HTTP 429, retry after 6970s
因此,请将匿名限制视为测试额度,在生产环境中使用密钥。根据状态码和 Retry-After 进行分支判断,而不是依据 429 的消息文本。
这项检查的不足之处
IP 结论只是一个信号,它会漏掉一些情况。我们在撰写本文时遇到了四种:
- 已列出的 VPN 服务器可能返回干净的结果。 PIA 的服务器
91.90.122.25在 10 月 4 日出现在 PIA 自己公布的列表中,但我们的 API 返回is_vpn: false。它之所以达到step_up,只是因为它在一个 DNS 黑名单上。如果没有这段历史,它就会作为普通托管地址被放行。 - 住宅代理对 IP 数据而言大多是不可见的。 经由真实家庭连接转发的流量看起来就像家庭用户。
is_residential_proxy字段是基于网络的粗略推断,而不是检测结果,因此不要基于它构建规则。 - 共享地址承载着他人的历史。 移动运营商和 CGNAT 让许多用户共用一个 IP,这就是为什么评分会对 CGNAT 段设置上限,也是为什么在这些地址上
block应该很少出现。 - 中继的位置只是近似值。 对于
104.28.46.99,我们的地理定位显示为安哥拉罗安达,而 Apple 自己的出口列表将该段定位在赞比亚卢萨卡。决策时请使用中继标记而不是城市,正如我们的放行 Private Relay 指南所解释的。
将 IP 检查与您对账户的了解结合起来:邮箱注册时长、设备、支付历史和请求频率。支付欺诈技术栈指南展示了 IP 信号在其中所处的位置。
先在您自己的流量上运行
在将其接入线上流程之前,使用免费密钥对过去一天的注册 IP 运行任一脚本,并将其给出的操作与您对这些账户已有的了解进行比较。如果 block 能捕获您后来封禁的账户,而 allow 覆盖了您保留下来的客户,那么请先将它部署在注册环节之前,然后是登录,再然后是结账。如需手动检查单个地址,IP 欺诈评分检测工具无需任何代码即可显示相同的评分和原因。
常见问题
GeoIPHub API 可以免费开始使用吗?
可以。免费套餐每天包含 1,500 次查询,提供所有数据字段、一个 API 密钥,且无需信用卡。不使用密钥时,您每分钟可以进行 30 次查询用于测试。
我的代码应该根据哪个字段进行分支判断?
scoring.recommended_action。评分不超过 25 时为 allow,不超过 50 时为 review,不超过 75 时为 step_up,高于 75 时为 block。读取 scoring.detection_methods 来记录原因。
为什么我的 Python 脚本收到 403?
我们的 API 对 urllib 的默认 User-Agent(Python-urllib/3.x)返回 403 Forbidden。设置您自己的 User-Agent 请求头后,同样的请求就会返回 200。
可以缓存响应吗?
可以,在您自己的代码中缓存。API 会发送 Cache-Control: private, no-store,因此共享 HTTP 缓存不会保存它。我们对每个 IP 的结论复用一小时。
它会拦截我的真实客户吗?
只要您按设计使用这四种操作,就不会。在我们的测试中,一个 iCloud Private Relay 地址返回 allow,评分为 9;一台普通云服务器返回 allow,评分为 10。只有 Tor 出口和一个经探测验证的 VPN 达到了 block。
我需要发送个人数据吗?
不需要。您发送一个 IP 地址,获得关于该地址的情报。姓名、电子邮件和银行卡信息都留在您自己的系统中。
GeoIPHub API 背后的团队
由 GeoIPHub 团队撰写,即构建 GeoIPHub IP 情报 API 及其扫描器的团队。草稿在 AI 辅助下完成,并由团队编辑;除非标注为示例,API 示例均为针对线上 API 的真实查询。


