목차
- TOTP 2단계 인증 구현, 출시 첫날의 에러 로그
- TOTP가 SMS·이메일 인증과 어떻게 다른가
- TOTP 알고리즘 — 30초 윈도우와 HMAC
- Python으로 구현 — pyotp와 시간 동기 함정
- Node.js로 구현 — speakeasy와 검증 윈도우
- 백업 코드와 복구 플로우 — 진짜 어려운 부분
- 운영 체크리스트 — 배포 전 확인할 것
- 자주 빠지는 함정 한 가지
TOTP 2단계 인증 구현, 출시 첫날의 에러 로그
[2026-03-14 09:21:33] ERROR auth.verify_otp
ValueError: Invalid TOTP code
user_id=u_8231, code_attempt=403891
server_time=1741938093, client_time=1741938152
drift=+59s, valid_window=±30s
[2026-03-14 09:22:11] ERROR auth.verify_otp
ValueError: Invalid TOTP code
user_id=u_8245, drift=+62s
[2026-03-14 09:25:08] ERROR auth.verify_otp
drift=+58s
TOTP 2단계 인증 구현은 외관만 보면 pyotp.TOTP(secret).verify(code) 한 줄짜리 작업처럼 보인다. 그런데 출시 직후 사용자 30%가 "코드가 안 맞다"는 문의를 보냈다. 로그를 보니 서버와 클라이언트 시각이 1분 가까이 어긋나 있었다. 컨테이너 이미지에 chrony도 systemd-timesyncd도 안 깔린 채 떠 있던 게 원인이다.
이 글은 그 사고 이후 다시 설계한 TOTP 2단계 인증 구현을 단계별로 풀어본다. Python과 Node.js 코드를 같이 두고, QR 발급·검증·백업 코드·락아웃 복구까지 실제로 막히는 지점을 짚는다. 프론트엔드만 하다가 백엔드로 넘어온 신입이 이 작업을 맡았다고 가정하고 썼다.
TOTP가 SMS·이메일 인증과 어떻게 다른가
TOTP(Time-based One-Time Password)는 RFC 6238에 정의된 표준이다. 서버와 클라이언트가 같은 비밀키(시크릿)를 공유하고, 30초마다 같은 6자리 코드를 독립적으로 계산한다. 네트워크를 안 타기 때문에 통신비도 안 들고, 가로채기도 어렵다.
한편, 프론트엔드만 하던 시절에는 인증을 그냥 "로그인 폼 만드는 일"로 봤다. 백엔드로 넘어와서야 인증 방식마다 비용·보안·UX 트레이드오프가 다르다는 걸 알게 됐다.
| 방식 | 평균 비용/건 | 가로채기 위험 | 오프라인 동작 |
|---|---|---|---|
| SMS OTP | $0.005~0.05 | SIM 스왑 공격에 취약 | 불가 |
| 이메일 OTP | ~$0.0001 | 메일 계정 탈취 시 무력 | 불가 |
| TOTP | $0 (사용자 폰) | 시크릿이 새지 않는 한 안전 | 가능 |
| WebAuthn/Passkey | $0 | 가장 안전한 편 | 가능(기기 제한) |
결국, 비용만 보면 TOTP가 압도적이라고 쓰려다 멈췄다. WebAuthn이 더 안전한 건 사실이다. 다만 2026년 6월 기준으로도 Passkey를 모든 운영체제·브라우저 조합에서 매끄럽게 쓰는 건 아직 까다로운 편이다(특히 데스크톱 Linux). TOTP는 호환성 면에서 여전히 1순위 후보다.
TOTP 알고리즘 — 30초 윈도우와 HMAC
물론, 작동 원리 자체는 단순하다.
- 서버가 사용자별로 무작위 시크릿(보통 160비트, base32 인코딩)을 만든다
- 시크릿을 QR로 보여주고, 사용자가 Google Authenticator·1Password·Authy 같은 앱에 등록한다
- 검증 시점에 서버와 앱은 각자
HMAC-SHA1(secret, floor(현재 유닉스시각 / 30))을 계산해서 6자리로 자른다 - 두 값이 같으면 통과
핵심은 floor(time / 30) 부분이다. 30초 단위로 끊긴 카운터를 키로 쓰기 때문에, 서버 시각이 30초 이상 어긋나면 영원히 코드가 안 맞는다. 그래서 시간 동기가 TOTP 운영의 절반이라고 봐도 된다.
코드 길이를 6자리에서 8자리로, 알고리즘을 SHA-1에서 SHA-256으로 바꿀 수도 있다. 단 Google Authenticator 같은 일반 앱이 SHA-1 6자리 외에는 지원이 들쭉날쭉한 편이다(출처: Google Authenticator 공식 사양 문서). 호환성 때문에 기본값을 그대로 쓰는 게 안전하다.
Python으로 구현 — pyotp와 시간 동기 함정
라이브러리는 pyotp 2.9.0 기준이다(작성 시점). FastAPI 핸들러 예시로 풀어본다.
시크릿 생성과 QR 발급
# pip install pyotp qrcode[pil]
import pyotp
import qrcode
import io
import base64
def create_totp_secret(user_email: str, issuer: str = "MyApp"):
secret = pyotp.random_base32() # 32자 base32 문자열
totp = pyotp.TOTP(secret)
# otpauth:// URI를 만들어 QR로 인코딩
uri = totp.provisioning_uri(name=user_email, issuer_name=issuer)
img = qrcode.make(uri)
buf = io.BytesIO()
img.save(buf, format="PNG")
qr_b64 = base64.b64encode(buf.getvalue()).decode()
return {"secret": secret, "qr_data_url": f"data:image/png;base64,{qr_b64}"}
결국, 여기서 secret은 DB에 평문으로 저장하면 안 된다. KMS나 Fernet 같은 대칭키로 암호화해서 넣는다. 시크릿이 유출되면 그 사용자의 모든 TOTP 코드가 노출된다. QR 이미지도 디스크에 안 남기는 게 좋다. 위 코드처럼 메모리에서만 처리하고 응답으로 흘려보낸다.
검증과 윈도우 설정
def verify_totp(secret_encrypted: bytes, code: str) -> bool:
secret = decrypt_secret(secret_encrypted)
totp = pyotp.TOTP(secret)
# valid_window=1 → ±30초 허용 (총 90초 윈도우)
return totp.verify(code, valid_window=1)
valid_window=1을 빼먹으면 안 된다. 사용자가 코드 입력하다가 30초 경계를 넘기는 경우가 흔하기 때문이다. 윈도우를 2 이상으로 늘리면 보안이 약해진다. 1이 합리적 선이다.
서버 시간 동기
따라서, 이 부분이 첫 사고의 진짜 원인이었다. Docker 컨테이너에서 시각이 표류하는 경우가 의외로 많다. 작성 시점 권장 설정은 다음과 같다.
# 호스트에 chrony 설치
sudo apt install chrony
sudo systemctl enable --now chrony
# 컨테이너에서 직접 확인
docker exec myapp date -u
docker exec myapp chronyc tracking # chrony가 있다면
한편, Kubernetes에서 돌리는 경우 노드 시각은 보통 kubelet이 동기하지만, EKS·GKE라도 시간 표류 모니터링은 따로 붙여야 한다. Prometheus의 node_timex_offset_seconds 메트릭을 30초 이상에서 알람으로 묶는 게 표준이다(출처: Prometheus node_exporter v1.7 문서, github.com/prometheus/node_exporter).
Node.js로 구현 — speakeasy와 검증 윈도우
게다가, Node 쪽은 speakeasy 2.0.0 + qrcode 1.5.x 조합을 많이 쓴다. Express 핸들러로 풀어본다.
// npm install speakeasy qrcode
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
async function createSecret(userEmail) {
const secret = speakeasy.generateSecret({
name: `MyApp (${userEmail})`,
issuer: 'MyApp',
length: 20, // 160비트
});
const qrDataUrl = await QRCode.toDataURL(secret.otpauth_url);
return {
secret: secret.base32,
qrDataUrl,
};
}
function verifyToken(secretBase32, token) {
return speakeasy.totp.verify({
secret: secretBase32,
encoding: 'base32',
token,
window: 1, // pyotp의 valid_window=1과 동일
});
}
Python과 Node를 섞어 쓰는 환경에서는 한 가지 확인 작업이 의외로 중요하다. 같은 시크릿·같은 시각에 두 언어로 코드를 찍어봐서 동일한 6자리가 나오는지 본다. 두 라이브러리 모두 RFC 6238을 따르지만, base32 패딩 처리에서 미묘하게 다른 동작을 본 적이 있다(라이브러리 버전 따라 차이가 있는 편). 인증 서비스만 Python으로 분리하고 나머지가 Node인 구조라면 통합 테스트에 이걸 꼭 넣어둔다.
백업 코드와 복구 플로우 — 진짜 어려운 부분
여기가 출시 첫 달의 두 번째 사고였다. 폰을 분실한 사용자한테 "지원팀에 문의해주세요"라고만 답한 케이스가 7건 쌓였다. 백업 코드를 안 만든 게 원인이다.
백업 코드 설계 기준
- 길이: 10자리(영숫자), 8개 발급
- 해싱: bcrypt 또는 argon2로 저장(평문 절대 금지)
- 사용: 1회용, 사용 즉시 무효화
- 재발급: 사용자가 요청하면 기존 전체 무효화 후 새로 8개
import secrets
import string
from passlib.hash import argon2
def generate_backup_codes(n: int = 8) -> tuple[list[str], list[str]]:
alphabet = string.ascii_uppercase + string.digits
plain_codes = []
hashed = []
for _ in range(n):
code = "".join(secrets.choice(alphabet) for _ in range(10))
plain_codes.append(code)
hashed.append(argon2.hash(code))
return plain_codes, hashed # plain은 한 번만 사용자에게 노출
사용자에게 보여주는 시점
백업 코드는 TOTP 등록을 마친 직후 한 번만 평문으로 보여준다. PDF·텍스트 다운로드 버튼을 같이 제공하는 게 표준이다. 사용자가 페이지를 닫고 나면 다시 평문을 보여줄 수 없다.
복구 플로우
- 사용자가 로그인 화면에서 "TOTP 코드를 잃어버렸다" 선택
- 등록된 이메일로 일회용 인증 링크 발송(15분 만료)
- 링크 클릭 후 백업 코드 입력 화면 표시
- 백업 코드 검증 통과 시 임시 세션 발급 + TOTP 재등록 페이지로 이동
- 기존 시크릿 폐기, 새 시크릿 발급, 새 백업 코드 8개 발급
물론, 이메일까지 잃은 경우는 신원 확인을 거친 수동 복구로 갈 수밖에 없다. 이걸 자동화하면 그 자체가 큰 보안 구멍이 된다.
운영 체크리스트 — 배포 전 확인할 것
신입이 같은 일을 맡는다면 이 리스트를 그대로 훑는 것만으로도 큰 사고를 거의 막을 수 있다.
- [ ] 시크릿을 KMS/Fernet 등으로 암호화해서 저장하는가
- [ ] DB 로그·애플리케이션 로그에 시크릿이 안 찍히는가(
secret,otpauth://마스킹 필터 적용) - [ ]
valid_window=1로 설정했는가 - [ ] 서버 NTP 동기 상태를 Prometheus나 CloudWatch로 모니터링하는가
- [ ] 시간 표류 30초 초과 시 알람이 울리는가
- [ ] 검증 실패 5회 이상 시 사용자 잠금 또는 추가 검증 트리거를 거는가
- [ ] 백업 코드를 argon2/bcrypt로 해싱해서 저장하는가
- [ ] 백업 코드를 사용자에게 평문으로 한 번만 노출하는가
- [ ] TOTP 재설정 시 기존 세션을 전부 무효화하는가
- [ ] 이메일 복구 링크 만료 시간이 15~30분 이내인가
- [ ] QR 이미지를 디스크에 남기지 않고 메모리에서만 처리하는가
- [ ] 검증 엔드포인트에 rate limit이 걸려 있는가(IP·계정 단위)
결국, 마지막 두 줄은 처음 구현했을 때 빼먹었다가 보안팀 리뷰에서 지적받은 부분이다. QR 이미지를 임시 디렉터리에 썼다가 cron이 안 지워서 한 달 치가 그대로 쌓여 있었다. 그 디렉터리는 결국 시크릿 누출 경로가 된다.
자주 빠지는 함정 한 가지
그래서, 검증 함수의 반환값을 그대로 응답에 노출하면 안 된다. "검증 실패"와 "사용자 없음"을 다른 메시지로 돌려주면 사용자명 enumeration 공격에 노출된다. 모든 실패는 "코드가 일치하지 않습니다"로 통일한다. 응답 시간도 비슷하게 맞춰야 timing 공격을 줄일 수 있다. bcrypt.checkpw처럼 항상 일정 시간을 소모하는 함수를 쓰거나, 분기마다 동일한 더미 연산을 끼워 넣는 방식을 쓴다.
또한, 다음에는 같은 백엔드 위에 WebAuthn(Passkey)을 추가해서 TOTP와 병행 운영하는 구조를 실험해볼 생각이다.
관련 글
- JWT RS256 HS256 차이, 환경별로 다른 답이 나오는 이유 – RS256이 무조건 정답이라는 통념을 깬다. 환경별로 답이 다르다. HS256, RS256, ES256을 키 관리와 검증 비용 기준으로 다…
- 세션 JWT 인증 비교: 로그아웃 안 되는 토큰 때문에 밤샌 이야기 (2026) – 세션 JWT 인증 비교를 다룬다. JWT로 갈아탔다가 로그아웃이 안 되는 문제로 헤맸던 경험, 그리고 어떤 기준으로 다시 결정했는지 정리한다.
- bcrypt argon2 비교 회고: 3개월 운영하고 알게 된 것들 – bcrypt argon2 비교를 신규 인증 모듈에 적용하면서 겪은 OOM과 파라미터 튜닝, 그리고 결국 어떤 조합으로 안정화됐는지 시간순으…