IP 사기 탐지 API: Node와 Python으로 가입, 로그인, 결제 점수화하기
가입, 로그인, 결제에 IP 사기 탐지 API를 추가하세요. 공개 IP 6개로 직접 실행한 Node 및 Python 코드와 실제 출력, 캐싱, 속도 제한을 소개합니다.

가입 요청 두 건이 1초 간격으로 들어오는데, 둘 다 사용자의 집 주소를 숨기고 있습니다. 하나는 함부르크의 Tor 출구에서 왔습니다. 다른 하나는 iCloud Private Relay에서 왔으며, Apple은 이를 "iCloud+ 구독의 일부"로서 "Safari에서 웹을 탐색할 때" 사용자를 보호한다고 설명합니다(Apple 지원). "숨겨진 모든 것"을 차단하는 규칙은 둘 다 거부하게 되는데, 그중 한 명은 비용을 지불하는 Apple 고객입니다.
이 둘을 구분하는 것이 핵심 과제입니다. 이는 IP 인텔리전스가 다루는 범위의 일부이며, 50줄 미만의 파일 하나로 구현할 수 있습니다. 아래에서 요청, 실제 응답 하나, 캐싱과 실패 처리가 포함된 Node 및 Python 버전, 여섯 개 주소에 대해 출력된 결과, 그리고 이 검사의 한계를 확인할 수 있습니다.

IP 사기 탐지 API에 필요한 단 하나의 요청
연동 전체는 경로에 IP를 넣고 x-api-key 헤더에 키를 담은 GET 한 번입니다. 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를 그대로 보냈더니{"message":"Invalid API key"}와 함께401이 반환되었습니다. 실제 키가 생기기 전까지는 헤더를 빼 두십시오. 익명 호출은 분당 30건까지 동작합니다. - 호출당 IP 하나. 각 요청은 주소 하나만 받으므로, 목록을 검사하는 스크립트는 주소마다 요청을 한 번씩 보내며 각각이 한도에 포함됩니다.
테스트 단계를 넘어서면 대시보드에서 무료 키를 만드십시오. 무료 플랜은 해당 키에 모든 필드를 포함해 하루 1,500건의 조회를 제공합니다.
Mullvad 주소에서 반환된 결과와 분기에 쓸 필드
응답은 10개 그룹으로 중첩된 약 2.7KB의 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"
}
}
이 주소는 Mullvad VPN이 서버를 운영하는 네트워크인 31173 Services의 말뫼 소재 주소입니다. 10월 13일에 확인했을 때는 Mullvad가 직접 공개한 서버 목록에는 없었습니다. 두 가지 신호가 발동했습니다. known_vpn_server는 알려진 VPN 호스팅 네트워크 표에서 나온 것이며, 그래서 vpn_confidence가 Mullvad 자체 서버 목록과 일치할 때의 95가 아니라 85입니다. 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, 1시간 캐시, 네 가지 분기
다음은 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는
block이 아니라review를 반환합니다. 저희 API에 접속할 수 없더라도 귀사의 가입은 계속 동작하며, 나중에 다시 살펴볼 수 있도록 기록됩니다. - 타임아웃은 3초입니다. 저희 쪽에서는 이미 알고 있는 IP를 메모리 맵 데이터베이스에서 1밀리초 미만에 응답하므로 대기 시간의 대부분은 네트워크 왕복입니다. 처음 보는 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 헤더는 예외 객체에서 읽습니다.
공개 주소 6개에 대해 두 스크립트가 출력한 결과
두 스크립트는 모든 주소에서 동일한 판정을 내렸습니다. 2026년 10월 16일 07:39 UTC에 Node 파일을, 07:40 UTC에 Python 파일을 키 없이 공개 주소 6개에 대해 실행했습니다. 마지막 명령은 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를 실행하자 동일한 7줄이 한 글자도 다르지 않게 출력되었습니다. 각 주소가 무엇이고 왜 그 결과가 나왔는지는 다음과 같습니다.
| 주소 | 정체 | 조치 | 발동한 신호 |
|---|---|---|---|
45.33.32.156 | scanme.nmap.org, Linode에 있는 Nmap의 공개 테스트 호스트 | 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 | 워싱턴 D.C.의 상용 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건 | 1분 안의 30번째 요청이 429를 반환하고 해당 IP를 두 시간 동안 차단 |
| 무료 키 | 하루 1,500건 | 일일 카운터가 초기화될 때까지 Retry-After 헤더와 함께 429 |
10월 16일 05:37 UTC에 429를 확인하려고 익명 요청을 한꺼번에 보냈습니다. 20초 안에 API가 Retry-After: 7199와 함께 429를 반환했고, 저희 스크립트를 포함해 저희 IP에서 오는 모든 익명 호출이 두 시간 동안 차단된 상태로 남았습니다. 실패 시 부드러운 처리 경로에서 얻은 첫 번째 실제 출력은 다음과 같습니다.
45.33.32.156 review - - lookup failed: rate limited, retry after 6971s
45.33.32.156 review - - HTTP 429, retry after 6970s
따라서 익명 한도는 테스트용 허용량으로만 간주하고, 운영 환경에서는 키를 사용하십시오. 429의 메시지 텍스트가 아니라 상태 코드와 Retry-After를 기준으로 분기하십시오.
이 검사의 한계
IP 판정은 하나의 신호일 뿐이며 놓치는 것이 있습니다. 이 글을 쓰면서 네 가지 사례를 만났습니다.
- 목록에 있는 VPN 서버가 깨끗하게 나올 수 있습니다. PIA의 서버
91.90.122.25는 10월 4일에 PIA가 직접 공개한 목록에 있었지만, 저희 API는is_vpn: false를 반환합니다. DNS 차단 목록에 올라 있어서step_up에 도달했을 뿐입니다. 그 이력이 없었다면 일반 호스팅으로 통과했을 것입니다. - 주거용 프록시는 대부분 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 주소는 점수 9로 allow가 반환되었고, 일반 클라우드 서버도 10으로 allow였습니다. block에 도달한 것은 Tor 출구와 프로브로 확인된 VPN뿐이었습니다.
개인정보를 보내야 합니까?
아니요. IP 주소를 보내면 해당 주소에 대한 인텔리전스를 받습니다. 이름, 이메일, 카드 정보는 귀사의 시스템에 그대로 남습니다.
GeoIPHub API를 만드는 팀
GeoIPHub IP 인텔리전스 API와 스캐너를 만드는 GeoIPHub 팀이 작성했습니다. 초안은 AI의 도움을 받아 준비하고 팀이 편집합니다. API 예시는 예시용으로 표시된 경우를 제외하고 라이브 API에 대한 실제 조회 결과입니다.


