문자 인증 보안 체크리스트: SMS 펌핑·인증 우회 막는 10가지
문자 인증 보안 점검 항목 10가지. SMS 펌핑 비용 폭탄을 막는 발송 제한, 인증번호 시도 횟수·만료, 클라이언트 응답 조작 우회, 번호 불일치까지 취약한 코드와 고친 코드로 정리했다.
문자 인증 보안은 발송 단계, 검증 단계, 상태 관리 단계를 따로 점검해야 한다. 발송 API에 번호·IP·전역 단위 제한과 비용 상한이 없으면 SMS 펌핑으로 문자 요금이 급증한다. 검증 API에 시도 횟수 제한과 만료가 없으면 6자리 인증번호도 무차별 대입에 뚫린다. 그리고 "인증 완료" 여부를 서버가 아니라 클라이언트가 들고 있으면, 앞의 두 단계를 아무리 잘 만들어도 응답값 하나만 바꿔서 우회할 수 있다.
실제 점검에서 가장 자주 나오는 결함은 의외로 단순하다. 인증 API는 멀쩡한데 가입 API가 phoneVerified: true 같은 요청 값을 그대로 믿는 경우가 있고, A 번호로 인증해 놓고 가입 요청에는 B 번호를 넣어도 통과하는 경우도 있다. 아래 10가지 항목을 순서대로 확인하면 이런 빈틈 대부분을 출시 전에 찾을 수 있다.
예시 코드는 Express와 Redis를 기준으로 한 가상 코드다. 다른 프레임워크를 쓰더라도 원리는 같다.
국내 SMS 인증 흐름과 공격 지점
국내 서비스에서 흔한 흐름은 다음과 같다.
POST /sms/send: 사용자가 휴대폰 번호를 입력하면 서버가 인증번호를 만들어 문자로 보낸다.POST /sms/verify: 사용자가 받은 번호를 입력하면 서버가 일치하는지 확인한다.POST /signup또는/password/reset: 인증이 끝난 상태에서 실제 업무 요청을 보낸다.
단계마다 노리는 것이 다르다. 1번은 비용이다. 공격자는 봇으로 발송을 반복시키거나, 공격자 쪽에 수익이 돌아가는 고가 번호 대역으로 문자를 보내게 만든다. 이것이 SMS 펌핑(SMS toll fraud)이다. 2번은 추측이다. 인증번호 공간이 작으니 시도 횟수가 무제한이면 언젠가 맞힌다. 3번은 논리다. 1·2번을 건너뛰거나, 인증 결과를 다른 번호나 다른 용도에 재사용한다.
OWASP API Security Top 10 2023의 API4(Unrestricted Resource Consumption)는 비밀번호 재설정 SMS 발송 API를 반복 호출해 짧은 시간에 큰 비용을 발생시키는 시나리오를 예로 들고, 외부 서비스에 지출 한도를 설정하고 그게 불가능하면 과금 알림이라도 걸라고 권고한다.
발송 단계: 비용 폭탄을 막는 4가지
1. 번호·IP·전역 단위로 발송 횟수를 겹겹이 제한한다
번호 하나에만 제한을 걸면, 공격자는 번호를 바꿔 가며 보낸다. IP 하나에만 걸면 IP를 돌린다. 그래서 제한을 여러 층으로 건다.
// 취약: 아무 제한 없이 요청마다 문자 발송
app.post("/sms/send", async (req, res) => {
const code = String(Math.floor(Math.random() * 1000000)).padStart(6, "0");
await smsProvider.send(req.body.phone, `[Example] 인증번호 ${code}`);
await redis.set(`otp:${req.body.phone}`, code);
res.json({ ok: true, code }); // 응답에 코드까지 노출
});
// 개선: 형식 검증 + 다층 레이트 리밋
const KR_MOBILE = /^01[016789]\d{7,8}$/;
async function within(key: string, limit: number, ttlSec: number) {
const n = await redis.incr(key);
if (n === 1) await redis.expire(key, ttlSec);
return n <= limit;
}
app.post("/sms/send", async (req, res) => {
const phone = String(req.body.phone ?? "").replace(/\D/g, "");
if (!KR_MOBILE.test(phone)) return res.status(400).json({ error: "INVALID_PHONE" });
const hour = new Date().toISOString().slice(0, 13);
const ok =
(await within(`sms:cool:${phone}`, 1, 60)) && // 같은 번호 60초 쿨다운
(await within(`sms:day:${phone}`, 5, 86400)) && // 같은 번호 하루 5회
(await within(`sms:ip:${req.ip}`, 20, 3600)) && // IP당 시간당 20회
(await within(`sms:global:${hour}`, 3000, 3600)); // 서비스 전체 시간당 상한
if (!ok) return res.status(429).json({ error: "TOO_MANY_REQUESTS" });
// ... 인증번호 생성·저장·발송 (아래 항목 참고)
});
수치는 서비스 규모에 맞춰 정한다. 판단 기준은 "정상 사용자가 하루에 이 번호로 인증을 몇 번 받을까"다. 전역 상한은 평소 최대치의 몇 배 정도로 잡아 두고, 넘으면 발송을 멈추고 담당자를 호출하는 서킷 브레이커로 쓴다. 프록시 뒤에 있다면 req.ip가 실제 클라이언트 IP인지(trust proxy 설정)도 확인해야 한다. 그렇지 않으면 IP 제한이 전체 사용자에게 한꺼번에 걸리거나, 위조한 X-Forwarded-For로 쉽게 우회된다.
2. 발송 가능한 번호 범위를 좁힌다
국내 사용자만 받는 서비스라면 국내 휴대폰 번호 형식만 허용하고, 국가 코드를 직접 입력받지 않는 것이 가장 효과적이다. SMS 펌핑은 대개 해외 고가 대역을 노린다. Twilio도 개발자 가이드에서 국가 코드와 접두번호로 입력값을 검증해 비싼 경로의 번호가 들어오지 못하게 하고, 쓰지 않는 국가로는 발송을 꺼 두라고 권고한다. 해외 번호를 꼭 받아야 한다면 허용 국가 목록을 명시적으로 관리하고, 그 대역에는 더 낮은 한도를 둔다.
3. 발송 전에 사람인지 확인한다
발송 버튼이 아무 사전 단계 없이 열려 있으면 봇이 곧바로 호출할 수 있다. 일정 횟수를 넘긴 요청부터 CAPTCHA를 요구하거나, 발송 전에 약관 동의·이메일 입력 같은 앞 단계를 서버 세션에서 확인하는 식으로 진입 비용을 높인다. Google reCAPTCHA의 SMS defense처럼 발송 전에 번호의 토큰 사기 위험 점수를 주는 서비스도 있고, 문자 발송 사업자가 자체 사기 탐지 기능을 제공하기도 한다. 다만 Twilio도 문서에서 밝히듯 사업자 측 차단이 완벽할 수는 없으므로 애플리케이션 쪽 제한과 함께 써야 한다.
4. 비용 상한과 이상 발송 알림을 건다
코드로 막는 것과 별개로, 문자 발송 사업자 콘솔에서 일·월 지출 한도나 잔액 알림을 설정한다. 한도 기능이 없다면 발송 로그를 기준으로 "시간당 발송량이 평소의 N배", "특정 번호 대역에 발송 집중", "발송 대비 인증 완료율 급락" 같은 지표에 알림을 건다. 마지막 지표가 특히 쓸모 있다. 펌핑 트래픽은 문자를 받기만 하고 인증은 하지 않으므로 완료율이 눈에 띄게 떨어진다.
검증 단계: 인증번호 대입을 막는 3가지
5. 인증번호는 안전한 난수로 만들고 해시로 저장한다
Math.random()은 보안용 난수가 아니다. crypto.randomInt처럼 암호학적으로 안전한 생성기를 쓴다. 저장소에는 평문 대신 해시를 저장하고, 요청 ID 같은 값을 섞어 다른 요청과 구분한다. 개발 편의로 넣은 "응답에 인증번호 포함"이나 "로그에 인증번호 출력" 코드는 운영 배포 전에 반드시 지운다. 점검에서 생각보다 자주 발견되는 항목이다.
6. 시도 횟수 제한·만료·일회성을 함께 적용한다
6자리 숫자는 경우의 수가 100만 개다. 인증번호 하나당 시도를 5회로 묶으면 무작위로 맞힐 확률은 그 인증번호 기준 100만분의 5로 떨어지고, 발송 횟수 제한(항목 1)과 곱해져 현실적인 공격 경로가 사라진다. OWASP Multifactor Authentication Cheat Sheet도 짧은 유효시간, 일회성 사용, 엄격한 시도 횟수 제한, 재발송 시 이전 코드 무효화를 권고한다.
// 개선: 요청 ID 단위 시도 횟수·만료·일회성
import crypto from "node:crypto";
const hashCode = (id: string, code: string) =>
crypto.createHash("sha256").update(`${id}:${code}`).digest();
// 발송 시: 새 requestId 발급, 같은 번호의 이전 요청은 폐기
async function issueOtp(phone: string, purpose: "signup" | "reset") {
const requestId = crypto.randomUUID();
const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
const prev = await redis.getset(`otp:latest:${phone}`, requestId);
if (prev) await redis.del(`otp:${prev}`);
await redis.hset(`otp:${requestId}`, {
phone, purpose, codeHash: hashCode(requestId, code).toString("hex"),
});
await redis.expire(`otp:${requestId}`, 180); // 3분
await redis.expire(`otp:latest:${phone}`, 180);
return { requestId, code };
}
app.post("/sms/verify", async (req, res) => {
const { requestId, code } = req.body;
const key = `otp:${requestId}`;
const rec = await redis.hgetall(key);
if (!rec.codeHash) return res.status(400).json({ error: "EXPIRED" });
// 비교보다 먼저 원자적으로 카운트를 올려 동시 요청에도 횟수가 정확히 쌓이게 한다
const attempts = await redis.hincrby(key, "attempts", 1);
if (attempts > 5) {
await redis.del(key);
return res.status(429).json({ error: "TOO_MANY_ATTEMPTS" });
}
const ok = crypto.timingSafeEqual(
hashCode(requestId, String(code)),
Buffer.from(rec.codeHash, "hex"),
);
if (!ok) return res.status(400).json({ error: "MISMATCH" });
await redis.del(key); // 일회성: 성공 즉시 폐기
// ... 항목 8의 인증 토큰 발급으로 이어짐
});
흔히 놓치는 지점은 비교한 뒤에 횟수를 올리는 구조다. 조회, 비교, 증가가 원자적이지 않으면 동시에 수십 개 요청을 보냈을 때 모두 "아직 0회"로 읽혀 제한이 무력해진다. 이 패턴은 레이스 컨디션 취약점에서 다룬 포인트 이중 사용과 원리가 같다.
7. 재발송으로 시도 횟수가 초기화되지 않게 한다
인증번호별로 5회를 줘도 재발송이 무제한이면 "5회 시도 → 재발송 → 5회 시도"가 반복된다. 재발송 횟수는 항목 1의 번호별 일일 제한으로 묶이는지 확인하고, 번호 단위 누적 실패가 일정 수를 넘으면 그 번호의 인증을 잠시 잠근다. 또 하나는 응답 차이다. "가입되지 않은 번호입니다"와 "인증번호를 보냈습니다"처럼 응답이 갈리면 회원 번호 열거에 쓰인다. 비밀번호 재설정 흐름에서는 응답 문구와 응답 시간을 같게 맞춘다.
상태 관리: 인증 우회를 막는 3가지
8. 인증 완료 여부는 서버가 기억하고, 번호에 묶는다
점검에서 가장 심각하게 분류되는 항목이다. 아래처럼 가입 API가 클라이언트가 보낸 플래그나 번호를 그대로 믿으면, 인증 API의 응답을 {"success": false}에서 true로 바꾸거나 요청 본문에 phoneVerified: true를 넣는 것만으로 인증을 건너뛸 수 있다. 앱이라면 화면 분기 로직이 디컴파일로 그대로 드러난다는 점도 기억하자(모바일 앱 API 보안).
// 취약: 인증 여부와 번호를 모두 클라이언트 값에 의존
app.post("/signup", async (req, res) => {
const { phone, phoneVerified, email, password } = req.body;
if (!phoneVerified) return res.status(400).json({ error: "VERIFY_REQUIRED" });
await createUser({ phone, email, password }); // 인증하지 않은 번호로도 가입됨
res.json({ ok: true });
});
// 개선: 검증 성공 시 서버가 번호·용도에 묶인 일회용 토큰 발급
// (/sms/verify 성공 지점)
const token = crypto.randomBytes(32).toString("base64url");
await redis.set(
`verified:${token}`,
JSON.stringify({ phone: rec.phone, purpose: rec.purpose }),
"EX", 600, // 10분 안에 가입을 마쳐야 함
);
return res.json({ verificationToken: token });
// 가입 API: 번호는 요청 본문이 아니라 토큰에서 꺼낸다
app.post("/signup", async (req, res) => {
const { verificationToken, email, password } = req.body;
const raw = await redis.getdel(`verified:${verificationToken}`); // 조회와 삭제를 한 번에
if (!raw) return res.status(400).json({ error: "VERIFY_REQUIRED" });
const { phone, purpose } = JSON.parse(raw);
if (purpose !== "signup") return res.status(400).json({ error: "WRONG_PURPOSE" });
await createUser({ phone, email, password });
res.json({ ok: true });
});
핵심은 세 가지다. 가입 요청에서 번호를 다시 받지 않는다. 받아야 하는 화면 구조라면 토큰에 저장된 번호와 일치하는지 서버에서 비교한다. 토큰은 한 번 쓰면 사라진다. 서버 세션을 쓰는 구조라면 토큰 대신 세션에 { phone, purpose, verifiedAt }을 저장해도 된다. 다만 세션 고정이 생기지 않도록 인증 완료 시점에 세션 ID를 재발급한다.
9. 인증 결과를 다른 용도에 재사용하지 못하게 한다
가입용 인증, 비밀번호 재설정용 인증, 휴대폰 번호 변경용 인증이 같은 토큰 형식을 쓰면 용도 간 재사용이 생긴다. 예를 들어 공격자가 자기 번호로 받은 가입용 인증 토큰을 다른 계정의 번호 변경 API에 넣는 식이다. 위 코드의 purpose 비교처럼 발송 시점부터 용도를 고정하고, 번호 변경이나 재설정처럼 계정에 영향을 주는 용도라면 현재 로그인한 계정 ID까지 토큰에 묶어 대상 계정과 일치하는지 확인한다. 클라이언트가 보낸 userId로 대상을 정하는 구조는 Mass Assignment 취약점과 같은 계열의 문제로 이어진다.
10. 우회 경로가 되는 다른 API를 같이 점검한다
인증을 거치는 정식 화면은 막혀 있어도 옛 버전 API, 관리자용 API, 소셜 로그인 후 추가 정보 입력 API가 인증 없이 번호를 저장하는 경우가 있다. 휴대폰 번호를 쓰거나 바꾸는 모든 엔드포인트를 목록으로 뽑고, 각각이 항목 8의 서버 측 인증 상태를 확인하는지 표시해 보자. 레이트 리밋도 API 게이트웨이나 공통 미들웨어처럼 모든 버전에 공통으로 걸리는 위치에 두는 편이 누락이 적다.
우리 서비스에서 확인하는 순서
자체 테스트 환경에서 아래 순서대로 확인하면 반나절 안에 대부분을 볼 수 있다.
- 같은 번호로 발송을 연속 요청하면 쿨다운과 일일 한도에서 429가 나오는가
- 번호를 바꿔 가며 같은 IP에서 요청하면 IP 한도에 걸리는가
-
+국가 코드나 비정상 형식 번호가 거절되는가 - 발송 응답, 서버 로그, 에러 메시지 어디에도 인증번호가 보이지 않는가
- 틀린 번호를 6번 넣으면 해당 인증번호가 폐기되는가
- 같은 요청을 동시에 여러 개 보내도 시도 횟수가 정확히 쌓이는가
- 3분이 지난 인증번호, 재발송 전 인증번호가 거절되는가
- 인증 API 응답을 프록시 도구로
true로 바꿔도 가입이 거절되는가 - A 번호로 인증한 뒤 가입 요청에 B 번호를 넣으면 거절되는가
- 가입용 인증 토큰을 비밀번호 재설정·번호 변경 API에 넣으면 거절되는가
- 문자 사업자 콘솔에 지출 한도 또는 알림이 설정돼 있는가
로그인 전반의 점검 항목은 로그인 보안 체크리스트에 따로 정리해 두었다.
자주 묻는 질문
인증번호 유효시간과 시도 횟수는 얼마가 적당한가요?
정해진 단일 기준은 없다. 국내 서비스에서는 유효시간 3~5분, 인증번호당 시도 5회 안팎이 흔하다. 시간보다 중요한 것은 시도 횟수 제한, 재발송 시 이전 번호 폐기, 번호별 발송 한도가 함께 걸려 있느냐다.
CAPTCHA를 붙이면 레이트 리밋은 없어도 되나요?
아니다. CAPTCHA는 자동화 비용을 올릴 뿐이고 사람을 동원하거나 우회 서비스를 쓰는 공격까지 막지는 못한다. 번호·IP·전역 한도와 비용 상한이 기본이고, CAPTCHA나 위험 점수 서비스는 그 위에 얹는 층으로 본다.
SMS 인증만으로 본인확인이나 2단계 인증을 해도 괜찮을까요?
OWASP는 SIM 스와핑, 번호 이동, 통신망 가로채기 위험을 이유로 SMS를 약한 인증 수단으로 분류하고, 고가치 서비스나 금융 위험이 있는 기능에는 권장하지 않는다. 회원가입 시 번호 확인 용도로는 흔히 쓰지만, 계정 보호용 2단계 인증이라면 TOTP나 패스키 같은 더 강한 수단을 함께 제공하는 쪽을 검토하자.
정리
- 발송 API에는 번호·IP·전역 단위 한도, 국내 번호 형식 제한, 사업자 지출 한도를 겹겹이 건다.
- 검증 API에는 안전한 난수, 원자적인 시도 횟수 카운트, 짧은 만료, 일회성 폐기를 적용한다.
- 인증 완료 상태는 서버가 번호·용도에 묶어 기억하고, 가입 요청의 번호는 클라이언트가 아니라 서버 기록에서 꺼낸다.
- 위 체크리스트로 테스트 환경에서 직접 확인해 보고, 직접 점검이 어렵다면 무료 분석 신청으로 외부 점검을 받아보는 것도 방법이다.
참고 자료
함께 읽으면 좋은 글
FastAPI 보안 가이드: 배포 전 점검할 설정 실수 7가지
FastAPI 보안 점검에서 자주 나오는 7가지를 정리했습니다. /docs 노출, Depends 인증 누락, 응답 모델 미지정, CORS, uvicorn 프록시 헤더, 의존성 취약점을 확인 명령과 수정 코드로 다룹니다.
오픈 리다이렉트 취약점: OAuth 토큰 탈취 경로와 검증법
오픈 리다이렉트 취약점이 피싱을 넘어 OAuth 인가 코드·토큰 탈취로 이어지는 원리, startsWith·정규식 검증이 우회되는 패턴, returnUrl을 허용 목록으로 검증하는 코드와 점검 체크리스트를 정리했습니다.
오픈소스 라이브러리 취약점 점검: npm audit 이후 8가지
오픈소스 라이브러리 취약점 점검을 경고 개수 줄이기가 아니라 위험 판단으로 바꾸는 법. 도달 가능성 판단, lockfile 관리, 버려진·타이포스쿼팅 패키지 확인, 컨테이너 이미지 점검까지 8가지 체크리스트로 정리했습니다.