ブログに戻る
セキュリティ

B2B SaaSサインアップ保護の実践ガイド

Josselin Liebe
Josselin Liebe

先月、B2B SaaSクライアントから相談を受けました。表面的にはサインアップは正常に見えるのに、無料クレジットが急速に消費されているという問題です。ログを分析すると、協調的な攻撃が判明しました — 同じ /24 サブネットからの数十件の登録、使い捨てメールのローテーション、複数の偽アカウントでインフラを再利用する単一の攻撃者。

この記事では、推奨した防御スタックを説明します。名前と識別子は匿名化していますが、フローは本番環境で使える状態です。

攻撃を容易にした要因

攻撃者に有利に働いた3つの要因:

  1. サインアップ時に全無料クレジットを付与 — 摩擦なし、認証なし、即座に価値を得られる。
  2. デバイス紐付けなし — 同じブラウザフィンガープリントで何度も登録可能。
  3. IPチェックの欠如または誤設定 — Cloudflareプロキシ(オレンジクラウド)の背後で、アプリが訪問者の実IPではなくプロキシIPを読んでいた。

サインアップのセキュリティは銀の弾丸ではなく、相互に強化されるシグナルを重ねることです。

優先度1:実IPアドレスを読み取る

Cloudflareプロキシが有効(オレンジクラウド)の場合、req.ipX-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_ipsthreat_score >= 50 のIPを自動ブロック
  • require_fingerprint_match — メール認証でフィンガープリント紐付けを強制
  • rate_limit_by_fingerprint — フィンガープリントベースのスロットリングを有効化
  • grant_credits_on_signup — 攻撃中は false のまま

これらのフラグで、インシデント中に防御を強化し、トラフィックが正常化したら緩和できます。

最終手段:通知せずに遅延させる

IPが明らかに悪用されているが攻撃者に気づかせたくない場合、一部のチームは人工的な摩擦を導入します — 応答の遅延、2回目の試行でのCAPTCHA、届かない偽SMSコード。これは議論の余地があり、最終手段として、内部で明確にログを取り、正当なユーザーには決して見せないべきです。

目標は攻撃者の時間を消耗させることであり、あなたの時間ではありません。

結果

このスタック — 実IP抽出、Veille IP・メールチェック、FingerprintJSレート制限、フィンガープリント紐付け認証、段階的クレジット — を実装後、クライアントの悪用サインアップ量は48時間以内に大幅に減少しました。正当なユーザーは追加チェックに気づくことなくオンボーディングを完了しました。

関連記事