TOTP 2FA 구현 (otpauth)
언제 — 앱 기반 2FA(TOTP, RFC 6238)를 직접 붙일 때. 먼저 직접 구현할 일인지부터 가른다.
- TOTP 직접 구현이 디폴트다. 위탁할 “OTP 서비스”라는 게 개념적으로 없다.
- 매니지드 인증(Auth0·Cognito·Firebase·Clerk·Supabase)을 쓰면 MFA 가 기능으로 내장돼 만질 일이 없다. 단 이건 “인증 전체 위탁”이지 “OTP 만 위탁”이 아니다.
- SMS/Email OTP 는 별개다. 생성은
랜덤 + Redis TTL로 trivial 하고, 서드파티를 쓰는 이유는 전달 인프라 때문이다. - 금융 OTP·본인인증은 규제 영역이라 외부 인증기관이 강제된다. 직접 구현 대상이 아니다.
멘탈 모델을 먼저 잡는다. TOTP 는 서버와 Authenticator 앱이 secret 을 공유한 뒤 각자 독립적으로 같은 코드를 계산하는 구조다. 등록 시점(QR 스캔) 이후로는 둘 사이에 통신이 없다.
[등록 시 단 한 번] 서버 ──(secret 을 QR 로)──> 사용자 폰 앱
[그 이후 통신 0] 서버: secret + 현재시각 → HMAC → 코드 A
앱: secret + 현재시각 → HMAC → 코드 B
사용자가 B 입력 → 서버가 A == B 비교
- ❌ “OTP 검증 서버에 요청” → 그런 서버 없다. 라이브러리 안에 네트워크 호출 0.
- ❌ “서버가 사용자에게 코드 발송” → 그건 SMS/Email OTP, 별개 물건.
라이브러리는 토큰 생성·검증(순수 알고리즘)만 담당한다. secret 암호화 저장·복구 코드·재사용 방지·throttling 은 전부 직접 구현이다.
R1. 등록 — secret 생성 & QR 용 URI (서버)
import * as OTPAuth from "otpauth"
const secret = new OTPAuth.Secret({ size: 20 }) // 최소 128비트, 160비트(20B) 권장
const totp = new OTPAuth.TOTP({
issuer: "Acme", // 서비스명 (앱에 그룹명으로 표시)
label: "user@example.com", // 계정 식별자
algorithm: "SHA1", digits: 6, period: 30,
secret,
})
const uri = totp.toString()
// otpauth://totp/Acme:user@example.com?issuer=Acme&secret=…&algorithm=SHA1&digits=6&period=30
const secretBase32 = secret.base32 // 저장할 값 — 아직 pending, R2 통과 전까지 활성화 X
QR 이미지 생성은 라이브러리 범위 밖이라 qrcode 같은 별도 패키지로 uri 를 인코딩한다.
함정 — algorithm·digits·period 는 특별한 이유 없으면 기본값을 고정한다. 대부분의 Authenticator 앱이 SHA1/6/30 만 안정적으로 지원한다.
R2. 등록 확정 — 초기 검증
QR 스캔 직후 앱에 뜬 코드를 입력하게 해서 secret 이 제대로 공유됐는지 확인한다. 여기 통과해야만 2FA 를 활성화한다.
const delta = totp.validate({ token: userInput, window: 1 })
if (delta !== null) {
// secret 을 암호화해 저장 + 2FA 활성 플래그 on + 복구 코드 발급
} else {
// 재입력 요청 (등록 미완료 유지)
}
validate() 는 delta(정수) 또는 null 을 반환한다. null 이면 window 안에서 못 찾은 것 = 무효다.
R3. 로그인 시 검증 (매 로그인)
ID/PW 1차 인증 통과 후, DB 의 secret 으로 TOTP 를 재구성한다.
const totp = new OTPAuth.TOTP({
issuer: "Acme", label: "user@example.com",
algorithm: "SHA1", digits: 6, period: 30,
secret: OTPAuth.Secret.fromBase32(savedSecretBase32), // 복호화해 로드
})
const delta = totp.validate({ token: userInput, window: 1 })
보조 메서드 — totp.generate() 는 지금 이 순간의 코드(앱이 하는 계산과 동일, 디버깅용), totp.counter() 는 경과 구간 수(재사용 방지 키), totp.remaining() 은 코드가 바뀌기까지 남은 ms(UI 카운트다운).
URI ↔ 객체 — totp.toString() / OTPAuth.URI.parse(uri).
R4. window 와 clock drift
서버 시계와 폰 시계는 몇 초씩 어긋난다. window: N 은 현재 구간 기준 앞뒤 N개 구간(±N×period 초)까지 허용한다는 뜻이다.
window: 1,period: 30→ ±30초 허용 (실무 표준)- 키우면 유효 코드 개수가 늘어 브루트포스에 취약하다 → 최소화 원칙
라이브러리가 안 해주는 것 (전부 직접)
| 항목 | 처리 |
|---|---|
| secret 저장 | 평문 금지. 앱 레벨 암호화(KMS/envelope) 후 DB |
| secret 노출 | 서버에만 보관. 클라이언트엔 등록 시 QR 로 1회만 |
| window 크기 | 기본 1 유지 |
| 재사용 방지 | 성공한 counter() 값을 저장 → 같은 구간 코드 재검증 차단 |
| throttling | OTP 입력 시도 횟수 제한 (필수) |
| 복구 수단 | 복구 코드 별도 발급·해시 저장 |
보안 고려사항은 RFC 4226 §7, RFC 6238 §5.