B2B SaaSサインアップ保護の実践ガイド
先月、B2B SaaSクライアントから相談を受けました。表面的にはサインアップは正常に見えるのに、無料クレジットが急速に消費されているという問題です。ログを分析すると、協調的な攻撃が判明しました — 同じ /24 サブネットからの数十件の登録、使い捨てメールのローテーション、複数の偽アカウントでインフラを再利用する単一の攻撃者。
この記事では、推奨した防御スタックを説明します。名前と識別子は匿名化していますが、フローは本番環境で使える状態です。
攻撃を容易にした要因
攻撃者に有利に働いた3つの要因:
- サインアップ時に全無料クレジットを付与 — 摩擦なし、認証なし、即座に価値を得られる。
- デバイス紐付けなし — 同じブラウザフィンガープリントで何度も登録可能。
- IPチェックの欠如または誤設定 — Cloudflareプロキシ(オレンジクラウド)の背後で、アプリが訪問者の実IPではなくプロキシIPを読んでいた。
サインアップのセキュリティは銀の弾丸ではなく、相互に強化されるシグナルを重ねることです。
優先度1:実IPアドレスを読み取る
Cloudflareプロキシが有効(オレンジクラウド)の場合、req.ip や X-Forwarded-For だけでは不十分です。Cloudflareは CF-Connecting-IP ヘッダーで訪問者の実IPを送信します。
function getClientIp(request: Request): string {
const cfIp = request.headers.get("cf-connecting-ip");
if (cfIp) return cfIp;
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) return forwarded.split(",")[0].trim();
return "0.0.0.0";
}
レート制限、IPレピュテーション、ブロックなど、下流のすべてのチェックは、この最初のステップが正しいことに依存します。
優先度2:登録時にIPをスコアリング
サインアップ中に Veille IP Reputation API を非同期で実行します。可能であればHTTPレスポンスをブロックせず、チェックをキューに入れて数秒以内に対応します。
const API_KEY = process.env.VEILLE_API_KEY!;
const BASE_URL = "https://api.veille.io/v1";
async function scoreIp(ip: string) {
const response = await fetch(
`${BASE_URL}/intelligence/ip?query=${encodeURIComponent(ip)}`,
{ headers: { "x-api-key": API_KEY } }
);
return response.json();
}
threat_score は検出されたフラグを集約します:
| スコア | 意味 |
|---|---|
| 0 | クリーンな住宅またはビジネスIP |
| 1–25 | 軽微なフラグ(データセンター、VPN) |
| 25–50 | 中程度のリスク(複数フラグ) |
| 50+ | 高リスク(Tor、既知の悪用者、プロキシ) — ブロックまたは追加認証 |
クライアントのログでは、攻撃者IPが /24 レンジにクラスター化し、スコア50超でした。単一IPではなく、繰り返しの悪用後にサブネットをブロックすることで、量を迅速に削減しました。
優先度3:FingerprintJSを追加
FingerprintJS をサインアップ・ログインページにインストールします。すべての登録試行で visitorId を保存します。
import FingerprintJS from "@fingerprintjs/fingerprintjs";
async function getFingerprint(): Promise<string> {
const fp = await FingerprintJS.load();
const result = await fp.get();
return result.visitorId;
}
IPとフィンガープリントの両方でレート制限を適用:
- 同じフィンガープリントまたはIPから 5回のサインアップ/ログイン失敗 → 24時間ブロック。
- すべての試行で両方の値をログに記録し、バックオフィスで分析可能に。
優先度4:メールを非同期で検証
使い捨てメールは決意した攻撃者を止めませんが、低工数の悪用の大部分をフィルタリングします。IPチェックと並行して Veille Email Validation API を呼び出します:
async function validateEmail(email: string) {
const response = await fetch(
`${BASE_URL}/intelligence/email?query=${encodeURIComponent(email)}`,
{ headers: { "x-api-key": API_KEY } }
);
return response.json();
}
// サインアップハンドラー内:
const [ipResult, emailResult] = await Promise.all([
scoreIp(clientIp),
validateEmail(email),
]);
if (emailResult.disposable || emailResult.risk_score > 70) {
await blockIpTemporarily(clientIp, "disposable_email");
return { error: "Registration blocked" };
}
if (ipResult.threat_score >= 50) {
await blockIpTemporarily(clientIp, "high_threat_ip");
return { error: "Registration blocked" };
}
フォーム受付後、クレジット付与前にこれらのチェックを実行します。いずれかのシグナルが失敗した場合、設定可能な期間IPをブロックし、汎用エラーを返します。
優先度5:フィンガープリントをメール認証に紐付け
アカウント作成は、受信トレイを制御していることの証明とは異なります。クレジットが使える前にメール認証を必須にします。
多くのチームが見落とす重要なステップ:サインアップ時のフィンガープリントは、認証リンククリック時のフィンガープリントと一致する必要がある。
// サインアップ時 — 保留中の認証を保存
await db.pendingVerifications.create({
email,
fingerprintSignup: fingerprint,
ipSignup: clientIp,
token: verificationToken,
expiresAt: addHours(new Date(), 24),
});
// 認証時 — フィンガープリントを比較
const pending = await db.pendingVerifications.findByToken(token);
if (pending.fingerprintSignup !== currentFingerprint) {
await flagAccount(pending.email, "fingerprint_mismatch");
return { error: "Verification failed" };
}
await activateAccount(pending.email);
不一致が常に不正を意味するわけではありません — ユーザーはデバイスを切り替えます — しかし、高リスクIPからの繰り返しの不一致は強いシグナルです。
優先度6:サインアップ時に全クレジットを付与しない
無料クレジットは攻撃者のROIです。ボットが自動化しにくいアクションに価値を分散します:
| マイルストーン | クレジット | 理由 |
|---|---|---|
| アカウント作成 | 0 | 即座に抽出できる価値なし |
| メール認証済み | +10 | 受信トレイ制御を証明 |
| オンボーディング完了 | +25 | 実際の製品操作が必要 |
| SNSシェア確認 | +15 | 摩擦と監査証跡を追加 |
バックオフィスのフラグ例:
interface UserCredits {
emailVerified: boolean; // +10 credits
onboardingCompleted: boolean; // +25 credits
sharedOnSocial: boolean; // +15 credits
}
function getAvailableCredits(user: UserCredits): number {
let credits = 0;
if (user.emailVerified) credits += 10;
if (user.onboardingCompleted) credits += 25;
if (user.sharedOnSocial) credits += 15;
return credits;
}
ボットはクレジットへの最短経路を最適化します。実際のAPI呼び出し、設定選択、短いチュートリアルを含むオンボーディングは、スクリプト化されたフローを破ります。
全体像:サインアップフロー
ユーザーがフォームを送信
│
▼
CF-Connecting-IPを読み取り
│
▼
フィンガープリント + IPレート制限をチェック ──► ブロック? → 429, ログ
│
▼
アカウント作成(0クレジット)
│
├──► 非同期: IPレピュテーション
└──► 非同期: メール検証
│
▼
脅威検出? → IPブロック, アカウントフラグ
│
▼
認証メール送信(サインアップフィンガープリントを保存)
│
▼
リンククリック → フィンガープリント一致? → +10クレジット
│
▼
オンボーディング完了 → +25クレジット
│
▼
SNSシェア確認 → +15クレジット
バックオフィスに追加すべきブール値
デプロイなしで対応できるトグルをチームに提供:
block_disposable_emails— サインアップ時に使い捨て受信箱を拒否block_high_threat_ips—threat_score >= 50のIPを自動ブロックrequire_fingerprint_match— メール認証でフィンガープリント紐付けを強制rate_limit_by_fingerprint— フィンガープリントベースのスロットリングを有効化grant_credits_on_signup— 攻撃中はfalseのまま
これらのフラグで、インシデント中に防御を強化し、トラフィックが正常化したら緩和できます。
最終手段:通知せずに遅延させる
IPが明らかに悪用されているが攻撃者に気づかせたくない場合、一部のチームは人工的な摩擦を導入します — 応答の遅延、2回目の試行でのCAPTCHA、届かない偽SMSコード。これは議論の余地があり、最終手段として、内部で明確にログを取り、正当なユーザーには決して見せないべきです。
目標は攻撃者の時間を消耗させることであり、あなたの時間ではありません。
結果
このスタック — 実IP抽出、Veille IP・メールチェック、FingerprintJSレート制限、フィンガープリント紐付け認証、段階的クレジット — を実装後、クライアントの悪用サインアップ量は48時間以内に大幅に減少しました。正当なユーザーは追加チェックに気づくことなくオンボーディングを完了しました。