IP 不正検知 API:Node と Python でサインアップ、ログイン、決済にスコアを付ける
サインアップ、ログイン、決済に IP 不正検知 API を組み込む方法。6 つの公開 IP で実行した Node と Python のコード、実際の出力、キャッシュとレート制限を紹介します。

1 秒違いで 2 件のサインアップが届き、どちらも本人の自宅アドレスを隠しています。1 件はハンブルクの Tor 出口から、もう 1 件は iCloud プライベートリレーからです。Apple はこれを、「Safari でウェブを閲覧するとき」に保護する「iCloud+ サブスクリプションの一部」と説明しています(Apple サポート)。「隠しているものはすべてブロックする」というルールでは両方が拒否され、片方は Apple の有料ユーザーです。
この 2 つを見分けることが仕事です。これは IP インテリジェンスが扱う範囲の一部であり、50 行に満たない 1 つのファイルに収まります。以下では、リクエスト、実際のレスポンス 1 件、キャッシュと障害処理を備えた Node 版と Python 版、6 つのアドレスに対する出力、そしてこのチェックの限界を順に説明します。

IP 不正検知 API に必要なのは 1 回のリクエストだけ
連携のすべては、パスに IP を指定し、x-api-key ヘッダーにキーを付けた 1 回の GET です。API リファレンスにも同じ形式が記載されています。
curl https://api.geoiphub.com/v1/lookup/45.83.220.7 \
-H "x-api-key: YOUR_API_KEY"
最初に引っかかりやすい点が 3 つあります。
- IP はパスに入れます。 使えるのは
/v1/lookup/{ip}の形式だけです。クエリ文字列の形式ではデータが返らず、リダイレクトされます。 - プレースホルダーを送らないでください。 文字列
YOUR_API_KEYをそのまま送ったところ、401と{"message":"Invalid API key"}が返りました。実際のキーを取得するまではヘッダーを付けないでください。キーなしの呼び出しは 1 分あたり 30 回まで使えます。 - 1 回の呼び出しにつき IP は 1 つです。 各リクエストが受け付けるアドレスは 1 つなので、リストを一括で確認するスクリプトはアドレスごとにリクエストを送り、それぞれが上限にカウントされます。
テストの段階を過ぎたら、ダッシュボードで無料のキーを作成してください。無料プランでは、そのキーでフィールドをすべて含む 1 日 1,500 回のルックアップが使えます。
Mullvad のアドレスで返ってきた内容と、分岐に使うフィールド
レスポンスは 10 個のグループに分かれた約 2.7 KB のネストした JSON ですが、不正チェックに必要なのは detection と scoring の 2 つだけです。次は 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 自身が公開しているサーバーリストには載っていませんでした。2 つのシグナルが発火しています。known_vpn_server は当社の既知の VPN ホスティングネットワークの表に基づくもので、そのため vpn_confidence は、Mullvad 自身のサーバーリストと一致した場合の 95 ではなく 85 になっています。datacenter_ip は、自宅回線ではなくホスティングであることを示します。両方を合わせてスコアは 46 となり、review に分類されます。VPN を使っているというだけでは不正とは言えないからです。
判定を左右するフィールドは 3 つです。
| フィールド | 内容 | コードでの使い方 |
|---|---|---|
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 点を引くため、フラグが 1 つ立っただけでアドレスが block に入ることはありません。IP 不正スコアのガイドで、何が数値を動かすのかを説明しています。
Node 版:fetch、1 時間キャッシュ、4 つの分岐
これは 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);
}
このコードには、意図的に選んだ判断が 4 つあります。
recommended_actionを出発点にし、上書きするのは 2 か所だけです。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 のファイルでは、理由を書いたコメントとともに独自の値を設定しています。
# 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 日に、Node のファイルを 07:39 UTC に、Python のファイルを 07:40 UTC に、キーなしで 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 プライベートリレーのエグレス | 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 |
冒頭の 2 件のサインアップは、Tor 出口が 100、プライベートリレーのユーザーが 9 と、両極端に分かれました。隠されたアドレスをすべてブロックするのではなくスコアを付ける理由は、この開きにあります。
結果は当社のデータの変化に応じて動きます。今回の書き直しまで、この記事では 45.83.220.7 を単なるホスティングとして、スコア 10 の allow と示していました。10 月 8 日までに当社のスキャナーがこれを Mullvad と結び付け、現在のスコアは 46 です。判定ごとにアクションとメソッドを記録しておけば、こうした変化に驚かされることなく、お客様のデータの中で気づけます。
呼び出す場所:すべてのサインアップ、ログイン、決済
サインアップ、ログイン、決済のすべてで、操作を確定する前にサーバー側でスコアを確認してください。この 3 つが不正を働く側が利益を得る場面であり、ユーザー 1 人あたりの回数も少ないため、1 日の枠に収まります。ページビューのたびに呼び出すことは避けてください。
各アクションが実際に意味することは次のとおりです。
| アクション | サインアップ | ログイン | 決済 |
|---|---|---|---|
allow | アカウントを作成する | サインインさせる | 支払いを受け付ける |
review | 作成し、レビュー用にフラグを立てる | サインインさせ、理由を記録する | コード側で step_up に引き上げる |
step_up | 先にメールまたは電話番号を確認する | 第 2 要素を求める | 3-D セキュアを実行する |
block | 当たり障りのないメッセージで拒否する | 拒否し、ロックアウトの回数に加算する | 注文を拒否する |
デフォルトの区分は出発点であり、お客様自身の不正データに合わせて動かすべきものです。ログイン、サインアップ、決済の不正スコアのしきい値では、アクションごとの初期カットオフとその根拠を紹介しています。
スコアを付ける対象のアドレスも正しく選んでください。ロードバランサーや CDN の背後では、ソケットのアドレスはお客様のプロキシであり、クライアントの IP は転送ヘッダーに入っていますが、このヘッダーはどのクライアントでも偽装できます。お客様が信頼するプロキシが追加したホップから取得し、クライアントが送った一番左の値は決して使わないでください。
1 日 1,500 回に収める:キャッシュとレート制限
無料プランで使えるのは 1 日 1,500 回のルックアップです。IP ごとにお客様のコード側でキャッシュすれば、予算を大きく引き延ばせます。1 人のユーザーが、たいてい 1 時間以内に同じアドレスからサインアップ、ログイン、支払いを行うためです。
API 側はキャッシュに協力しません。すべてのレスポンスに Cache-Control: private, no-store が付くため、共有 HTTP キャッシュは保存しないからです。両スクリプトとも、判定をプロセスのメモリ内に 1 時間保持します。サーバーが複数ある場合は、Redis や既存のキャッシュを使ってください。当社の脅威フィードとルックアップデータベースは毎日更新されるため、判定を 1 日より長く保持しないでください。
2 つの制限は動作が異なり、この記事のテスト中にそのうちの 1 つに当たりました。
| 呼び出し方 | 制限 | 超えた場合 |
|---|---|---|
| キーなし | 呼び出し元 IP ごとに 1 分あたり 30 回のルックアップ | 1 分以内の 30 回目のリクエストで 429 が返り、その IP は 2 時間ブロックされます |
| 無料キー | 1 日 1,500 回のルックアップ | 日次カウンターがリセットされるまで、Retry-After ヘッダー付きの 429 |
10 月 16 日の 05:37 UTC に、429 を確認するため匿名リクエストを連続で送りました。20 秒以内に API は Retry-After: 7199 付きの 429 を返し、当社の IP からの匿名の呼び出しはすべて 2 時間ブロックされたままになりました。当社自身のスクリプトも例外ではありません。次が、その失敗時の動作パスで得た最初の実際の出力です。
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 の判定は 1 つのシグナルにすぎず、見逃しもあります。この記事を書く中で、次の 4 つのケースがありました。
- リストにある VPN サーバーがクリーンと判定されることがあります。 PIA のサーバー
91.90.122.25は 10 月 4 日時点で PIA 自身の公開リストに載っていましたが、当社の API はis_vpn: falseを返します。step_upに達したのは、DNS ブロックリストに載っていたからにすぎません。その履歴がなければ、通常のホスティングとして通過していたでしょう。 - レジデンシャルプロキシは、IP データではほとんど見えません。 本物の家庭用回線を経由するトラフィックは、家庭のユーザーと同じに見えます。
is_residential_proxyフィールドは、ネットワークからの大まかな推定であって検出ではないため、これに基づくルールは作らないでください。 - 共有アドレスは他人の履歴を引き継ぎます。 モバイルキャリアと CGNAT は多数のユーザーを 1 つの IP の背後に置きます。そのため、スコアは CGNAT の範囲に上限を設けており、そこでの
blockは稀にとどめるべきです。 - リレーの位置情報は概算です。
104.28.46.99について、当社のジオロケーションはアンゴラのルアンダを示しますが、Apple 自身のエグレスリストでは、この範囲はザンビアのルサカになっています。判断には都市ではなくリレーのフラグを使ってください。詳しくはプライベートリレーを通す方法のガイドで説明しています。
IP のチェックは、アカウントについてすでに把握している情報と組み合わせてください。メールアドレスの経過年数、デバイス、支払い履歴、リクエストの頻度などです。決済不正対策スタックのガイドで、その中での IP シグナルの位置づけを紹介しています。
まず自社のトラフィックで試してください
本番のフローに組み込む前に、無料のキーを使って、過去 1 日分のサインアップ IP に対していずれかのスクリプトを実行し、そのアクションを、すでに把握しているアカウントの実態と比較してください。block が、後に停止したアカウントを捕捉できていて、allow が残した顧客をカバーしているなら、まずサインアップの前段に置き、次にログイン、最後に決済へと広げます。1 つのアドレスを手作業で確認するには、IP 不正スコアチェッカーを使えば、コードなしで同じスコアと理由が表示されます。
よくある質問
GeoIPHub API は無料で始められますか?
はい。無料プランでは、すべてのデータフィールドを含む 1 日 1,500 回のルックアップ、API キー 1 つが利用でき、クレジットカードは不要です。キーなしでも、テスト用に 1 分あたり 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 ごとに 1 時間再利用しています。
本物の顧客をブロックしてしまいませんか?
4 つのアクションを想定どおりに使えば、その心配はありません。今回の実行では、iCloud プライベートリレーのアドレスはスコア 9 で allow、通常のクラウドサーバーはスコア 10 で allow でした。block に達したのは Tor 出口と、プローブで確認された VPN だけです。
個人情報を送る必要はありますか?
いいえ。送るのは IP アドレスだけで、そのアドレスに関するインテリジェンスが返ります。氏名、メールアドレス、カード情報はお客様のシステム内にとどまります。
GeoIPHub API を支えるチーム
GeoIPHub IP インテリジェンス API とそのスキャナーを開発している GeoIPHub チームが執筆しています。下書きは AI の支援を受けて作成し、チームが編集しています。API の例は、「説明用」と明記したものを除き、本番 API に対する実際のルックアップです。


