---
type: note
kind: Recipe
title: TOTP 2FA 구현 (otpauth)
description: 등록·확정·검증 세 단계와, 라이브러리가 안 해주는 것들. OTP 검증 서버 같은 건 없다.
tags: ['totp', '2fa', 'authentication', 'security', 'typescript']
status: release
ctime: 2026-09-01
mtime: 2026-09-02
generated: { by: claude/opus-5, at: 2026-09-01T00:00:00Z }
---

**언제** — 앱 기반 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 (서버)

```ts
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 를 활성화한다.**

```ts
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 를 재구성한다.

```ts
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.

---
