TOTP 2단계 인증 구현 완전 가이드: Python·Node.js 백업 코드와 복구 플로우

목차

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

물론, 작동 원리 자체는 단순하다.

  1. 서버가 사용자별로 무작위 시크릿(보통 160비트, base32 인코딩)을 만든다
  2. 시크릿을 QR로 보여주고, 사용자가 Google Authenticator·1Password·Authy 같은 앱에 등록한다
  3. 검증 시점에 서버와 앱은 각자 HMAC-SHA1(secret, floor(현재 유닉스시각 / 30))을 계산해서 6자리로 자른다
  4. 두 값이 같으면 통과

핵심은 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·텍스트 다운로드 버튼을 같이 제공하는 게 표준이다. 사용자가 페이지를 닫고 나면 다시 평문을 보여줄 수 없다.

복구 플로우

  1. 사용자가 로그인 화면에서 "TOTP 코드를 잃어버렸다" 선택
  2. 등록된 이메일로 일회용 인증 링크 발송(15분 만료)
  3. 링크 클릭 후 백업 코드 입력 화면 표시
  4. 백업 코드 검증 통과 시 임시 세션 발급 + TOTP 재등록 페이지로 이동
  5. 기존 시크릿 폐기, 새 시크릿 발급, 새 백업 코드 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와 병행 운영하는 구조를 실험해볼 생각이다.

관련 글