B2B SaaS 가입 보안 실전 가이드
지난달 B2B SaaS 고객이 익숙한 문제로 찾아왔습니다. 표면적으로는 가입이 정상적으로 보였지만 무료 크레딧이 빠르게 소진되고 있었습니다. 로그를 분석한 결과 조직적인 공격이 드러났습니다 — 동일한 /24 서브넷에서의 수십 건의 등록, 일회용 이메일 로테이션, 여러 가짜 계정에 인프라를 재사용하는 단일 공격자.
이 글은 권장한 방어 스택을 설명합니다. 이름과 식별자는 익명화했지만, 흐름은 프로덕션 준비가 되어 있습니다.
공격을 쉽게 만든 요인
공격자에게 유리하게 작용한 세 가지:
- 가입 시 모든 무료 크레딧 즉시 부여 — 마찰 없음, 인증 없음, 즉각적인 가치.
- 기기 연결 없음 — 동일한 브라우저 지문으로 반복 가입 가능.
- 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 >= 50IP 자동 차단require_fingerprint_match— 이메일 인증 시 지문 연결 강제rate_limit_by_fingerprint— 지문 기반 스로틀링 활성화grant_credits_on_signup— 공격 중에는false유지
이 플래그로 사건 중 방어를 강화하고, 트래픽이 정상화되면 완화할 수 있습니다.
최후의 수단: 알리지 않고 지연
IP가 명백히 악용적이지만 공격자에게 알리고 싶지 않다면, 일부 팀은 인위적 마찰을 도입합니다 — 지연된 응답, 두 번째 시도에서 CAPTCHA, 도착하지 않는 가짜 SMS 코드. 논란의 여지가 있으며 최후의 수단으로, 내부에 명확히 로깅하고, 정당한 사용자에게는 절대 보여서는 안 됩니다.
목표는 공격자의 시간을 소모시키는 것이지, 당신의 시간이 아닙니다.
결과
이 스택 — 실제 IP 추출, Veille IP·이메일 검사, FingerprintJS 레이트 제한, 지문 연결 인증, 단계적 크레딧 — 구현 후 고객의 악용 가입량은 48시간 내 급격히 감소했습니다. 정당한 사용자는 추가 검사를 눈치채지 못하고 온보딩을 완료했습니다.