블로그로 돌아가기
보안

B2B SaaS 가입 보안 실전 가이드

Josselin Liebe
Josselin Liebe

지난달 B2B SaaS 고객이 익숙한 문제로 찾아왔습니다. 표면적으로는 가입이 정상적으로 보였지만 무료 크레딧이 빠르게 소진되고 있었습니다. 로그를 분석한 결과 조직적인 공격이 드러났습니다 — 동일한 /24 서브넷에서의 수십 건의 등록, 일회용 이메일 로테이션, 여러 가짜 계정에 인프라를 재사용하는 단일 공격자.

이 글은 권장한 방어 스택을 설명합니다. 이름과 식별자는 익명화했지만, 흐름은 프로덕션 준비가 되어 있습니다.

공격을 쉽게 만든 요인

공격자에게 유리하게 작용한 세 가지:

  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가 명백히 악용적이지만 공격자에게 알리고 싶지 않다면, 일부 팀은 인위적 마찰을 도입합니다 — 지연된 응답, 두 번째 시도에서 CAPTCHA, 도착하지 않는 가짜 SMS 코드. 논란의 여지가 있으며 최후의 수단으로, 내부에 명확히 로깅하고, 정당한 사용자에게는 절대 보여서는 안 됩니다.

목표는 공격자의 시간을 소모시키는 것이지, 당신의 시간이 아닙니다.

결과

이 스택 — 실제 IP 추출, Veille IP·이메일 검사, FingerprintJS 레이트 제한, 지문 연결 인증, 단계적 크레딧 — 구현 후 고객의 악용 가입량은 48시간 내 급격히 감소했습니다. 정당한 사용자는 추가 검사를 눈치채지 못하고 온보딩을 완료했습니다.

관련 글