TOTP 작동 원리: 인증 앱 코드 뒤의 알고리즘
일주일에 몇 번씩 인증 앱을 열어 6자리 코드를 입력합니다. 휴대폰과 서버는 서로 통신하지 않는데도 같은 코드가 양쪽에 떠 있고, 서버가 기대하는 값과 정확히 맞아떨어집니다. 같은 비밀 키로 30초마다 새 숫자를 뽑아내는 셈인데, 비결은 양쪽이 각자 돌리는 작은 결정론적 알고리즘 하나입니다. 코드가 네트워크를 오가지도 않고, 중앙 서버가 숫자를 나눠 주지도 않습니다.
RFC 6238에 정의된 TOTP(Time-based One-Time Password)는 시간 값에 HMAC을 걸고 그 결과를 잘라 내, 비밀 키와 현재 시각을 짧은 숫자 코드로 바꿉니다. 이중 인증(2FA)은 양쪽이 값을 주고받지 않고도 같은 값을 계산해 내는 데 기댑니다. 그래서 이 알고리즘이 곧 신뢰 모델 전부입니다.
먼저 실제 숫자를 넣어 알고리즘을 처음부터 끝까지 따라간 뒤, 대부분의 설명이 건너뛰는 나머지 절반을 짚습니다. 서버가 코드를 실제로 어떻게 검증하는지, 2FA가 무엇을 막고 무엇을 못 막는지를 가감 없이 정리했습니다. 읽으면서 TOTP 생성기로 실시간 코드를 직접 계산해 봐도 좋습니다.
TOTP란 정확히 무엇인가요?
RFC 6238에 정의된 TOTP(Time-based One-Time Password)는 비밀 키와 현재 시각을 묶어, 일정한 간격마다 바뀌는 짧은 코드를 만드는 알고리즘입니다. 인증 앱과 서버가 같은 비밀 키를 갖고 같은 시계를 읽으며 같은 계산을 돌리기 때문에, 코드를 한 번도 주고받지 않아도 결국 같은 코드가 나옵니다.
이 마지막 대목을 꼭 기억해 두세요. 설정 단계에서 오가는 것은 비밀 키뿐이고 코드 자체는 절대 전송되지 않으며, 그 뒤로는 양쪽이 알아서 코드를 뽑습니다. 등록 시점의 비밀 키와 로그인할 때 사용자가 치는 6자리를 빼면, 회선에서 가로챌 거리가 없습니다. 입력 셋이 하나의 출력으로 모이는 그림을 떠올리면 됩니다.
| 입력 | 역할 | 일반적인 값 |
|---|---|---|
| 비밀 키 | 등록 시 한 번 합의하는 오래 쓰는 키 | JBSWY3DPEHPK3PXP (Base32) |
| 시간 스텝 | 앞으로 째깍거리는 카운터 | 30초 윈도 |
| 출력 | 둘에서 도출된 짧은 코드 | 324550 |
비밀 키는 거의 항상 Base32(A–Z 글자와 2–7 숫자)로 적습니다. 이 문자 집합은 대소문자를 가리지 않고, 인쇄하든 손으로 치든 QR 코드에 담든 멀쩡히 버티기 때문입니다. 등록은 otpauth:// URI를 스캔하거나(인증 QR로 만들 수 있습니다) Base32 문자열을 직접 입력해서 합니다.
TOTP vs HOTP vs SMS vs Passkey: 2FA 지형도
TOTP는 여러 선택지 중 하나일 뿐이라, 제대로 고르려면 판 전체를 봐야 합니다. 한 줄로 요약하면 이렇습니다. TOTP는 HOTP의 카운터를 Unix 에폭 이후 흘러간 시간 스텝 수로 바꿔 놓은 것입니다. 나머지 차이는 피싱 저항성과 편의성, 그리고 어떤 인프라가 있어야 하는지 사이의 맞바꿈으로 정리됩니다.
| 방식 | 구동 요소 | 코드 수명 | 피싱 저항성? | 네트워크 필요? | 일반적 용도 |
|---|---|---|---|---|---|
| HOTP (RFC 4226) | 증가하는 카운터 | 사용 전까지 | 아니요 | 아니요 | 하드웨어 토큰, 레거시 |
| TOTP (RFC 6238) | 현재 시간 | ~30초 | 아니요 | 아니요 (등록 이후) | 인증 앱 |
| SMS OTP | 서버가 코드 전송 | 몇 분 | 아니요 | 예 (이동통신) | 소비자용 대체 수단 |
| 푸시 승인 | 서버가 기기에 확인 요청 | 요청마다 | 부분적으로 | 예 | 앱 기반 2FA |
| Passkey / FIDO2 | 공개 키 챌린지 | 요청마다 | 예 (출처 고정) | 예 | 최신 계정 |
표를 보면 패턴이 보입니다. TOTP와 HOTP는 한 번 등록하면 오프라인으로 돌아가서 튼튼하고 사생활도 지켜 주지만, 둘 다 그것만으로는 피싱을 막지 못합니다. 그럴싸한 가짜 페이지가 코드를 받아서 그대로 진짜 사이트에 흘려보낼 수 있으니까요. SMS는 네트워크 채널을 보태는 대신 자기 나름의 공격 표면을 끌고 들어옵니다. Passkey는 자격 증명을 사이트 출처에 묶어 피싱 틈을 닫고, 그래서 업계가 이쪽으로 움직이고 있습니다. TOTP는 강력하면서도 어디서나 쓰이고 공짜라는 묘한 균형점에 있는데, TOTP가 여태 이렇게 흔한 것도 그 때문입니다.
TOTP 알고리즘이 작동하는 방식, 단계별로
알고리즘은 네 단계로 끝납니다. 숫자를 그대로 따라 해 볼 수 있도록 RFC 테스트 비밀 키 JBSWY3DPEHPK3PXP와 고정한 Unix 시간 1700000000을 넣어 각 단계를 돌려 보겠습니다.
- Base32 비밀 키를 디코딩해 원시 키 바이트로 만듭니다.
- 현재 Unix 시간에서 시간 스텝 카운터를 계산합니다.
- 비밀 키로 카운터를 HMAC합니다.
- 다이제스트를 6자리 코드로 잘라 냅니다.
1단계 — Base32 비밀 키를 바이트로 디코딩
Base32는 문자 하나에 5비트를 담고, 디코더는 그 문자들을 다시 8비트 바이트로 다시 묶습니다. 비밀 키 JBSWY3DPEHPK3PXP는 원시 바이트 10개, 즉 48 65 6c 6c 6f 21 de ad be ef로 풀립니다. HMAC 키는 눈에 보이는 문자열이 아니라 바로 이 바이트 배열입니다.
2단계 — 시간 스텝 카운터 계산
카운터는 시작점 이후로 지나간 온전한 시간 스텝의 수입니다. T = floor((unixTime − T0) / period). RFC 기본값은 T0 = 0(Unix 에폭)과 period = 30입니다. unixTime = 1700000000을 넣으면 T = floor(1700000000 / 30) = 56666666이 됩니다. 이 정수는 다시 8바이트 빅엔디언 값 00 00 00 00 03 60 aa 2a로 인코딩됩니다. 카운터는 새 30초 윈도가 열릴 때만 바뀌므로, 코드는 한 윈도 동안 그대로 머물다가 한 번에 다음 값으로 넘어갑니다.
3단계 — 비밀 키로 카운터를 HMAC
알고리즘은 비밀 키 바이트를 키로 써서 8바이트 카운터에 HMAC-SHA1을 겁니다. HMAC은 키가 끼어드는 일방향 함수라, 비밀 키 없이는 다이제스트를 되돌리거나 멀쩡한 다이제스트를 지어낼 수 없습니다. 코드를 위조할 수 없는 근거가 여기에 있습니다. 우리 입력에 대한 다이제스트는 20바이트 1d 70 6e 94 1a c7 6b 6d 4a 46 dd 6f af a4 5f e3 35 11 bf 86입니다.
4단계 — 6자리 코드로의 동적 잘라 내기 (RFC 4226)
20바이트 다이제스트는 손으로 입력하기엔 너무 깁니다. 그래서 RFC 4226의 동적 잘라 내기로 여기서 숫자 하나를 끄집어냅니다. 마지막 바이트의 하위 니블을 오프셋으로 씁니다. 마지막 바이트는 0x86, 그 하위 니블은 6이니 오프셋은 6입니다. 그 자리부터 4바이트(6b 6d 4a 46)를 읽고, 값을 양수로 두기 위해 첫 바이트의 최상위 비트를 마스킹하면 정수 1802324550이 나옵니다. 이를 10^6으로 나눈 나머지를 구한 뒤 앞을 0으로 채웁니다. 1802324550 % 1000000 = 324550. 이 비밀 키로 지금 이 순간 앱 화면에 뜨는 코드가 바로 이것입니다.
아래는 별도 의존성 없이 브라우저 내장 Web Crypto API만 쓰는 JavaScript 구현입니다. 주석 하나하나가 위의 네 단계 중 하나에 대응됩니다.
// RFC 6238에 따른 TOTP — SHA-1, 6자리, 30초 주기 (기본값).
async function generateTotp(base32Secret, unixTime = Date.now() / 1000) {
// 1단계: Base32 비밀 키(A-Z, 2-7)를 원시 키 바이트로 디코딩.
const alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = '';
for (const ch of base32Secret.replace(/=+$/, '').toUpperCase()) {
bits += alpha.indexOf(ch).toString(2).padStart(5, '0');
}
const keyBytes = new Uint8Array(
bits.match(/.{8}/g).map((b) => parseInt(b, 2)));
// 2단계: counter = 에폭 이후 30초 스텝의 개수 (8바이트 빅엔디언).
let counter = Math.floor(unixTime / 30);
const msg = new Uint8Array(8);
for (let i = 7; i >= 0; i--) {
msg[i] = counter & 0xff;
counter = Math.floor(counter / 256);
}
// 3단계: 비밀 키로 카운터를 HMAC-SHA1.
const key = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
const hmac = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
// 4단계: 동적 잘라 내기 (RFC 4226) -> 6자리 코드.
const offset = hmac[hmac.length - 1] & 0x0f;
const binary = ((hmac[offset] & 0x7f) << 24) | (hmac[offset + 1] << 16) |
(hmac[offset + 2] << 8) | hmac[offset + 3];
return (binary % 1_000_000).toString().padStart(6, '0');
}
const code = await generateTotp('JBSWY3DPEHPK3PXP', 1700000000);
console.log(code); // -> "324550"
표준 라이브러리(hmac와 struct)만으로 같은 알고리즘을 옮긴 Python 버전입니다.
import base64, hmac, hashlib, struct, time
def totp(secret, for_time=None, period=30, digits=6, digest='sha1'):
if for_time is None:
for_time = time.time()
# 1단계: 비밀 키를 Base32 디코딩해 원시 키 바이트로 만든다.
key = base64.b32decode(secret.upper())
# 2단계: counter = 에폭 이후 시간 스텝의 개수 (8바이트 빅엔디언).
counter = int(for_time // period)
msg = struct.pack(">Q", counter)
# 3단계: 비밀 키로 카운터를 HMAC.
h = hmac.new(key, msg, digest).digest()
# 4단계: 동적 잘라 내기 (RFC 4226) -> N자리 코드.
offset = h[-1] & 0x0f
binary = ((h[offset] & 0x7f) << 24 |
(h[offset + 1] & 0xff) << 16 |
(h[offset + 2] & 0xff) << 8 |
(h[offset + 3] & 0xff))
return str(binary % (10 ** digits)).zfill(digits)
print(totp('JBSWY3DPEHPK3PXP', 1700000000)) # -> 324550
두 구현 모두 고정한 시간에 대해 324550을 내놓고, 둘 다 공식 RFC 6238 테스트 벡터를 그대로 재현합니다(가령 T = 59의 SHA-1 벡터는 94287082가 나옵니다). SHA-1을 SHA-256이나 SHA-512로 갈아 끼우거나 자릿수를 손대면, 반대편 검증기도 똑같은 선택을 그대로 맞춰야 합니다. 안 그러면 코드는 영영 맞아떨어지지 않습니다.
서버에서 TOTP 코드 검증하기
코드를 만드는 것은 시스템의 절반에 불과합니다. 나머지 절반은 서버가 사용자가 방금 친 6자리를 받아들일지 가려내는 일이고, 보안에 민감한 절충은 죄다 이 단계에 몰려 있습니다.
서버는 코드를 저장하지 않습니다. 비밀 키를 저장해 두고, 로그인할 때 그 키와 현재 시각으로 기대 코드를 다시 계산해 비교합니다. 걸림돌은 시계 오차입니다. 사용자 기기와 서버가 초 단위까지 딱 맞는 일은 드물어서, 엄격하게 같은지만 따지면 윈도 경계에 걸친 코드를 멀쩡한데도 튕겨 냅니다. 그래서 작은 검증 윈도를 둡니다. 현재 스텝과 앞뒤로 한 스텝씩, 곧 카운터 T−1, T, T+1의 코드를 함께 확인하는 방식입니다. 윈도를 넓히면 오차를 더 견디는 대신 추측 가능한 면적도 같이 커지므로, 보통은 윈도 1(±30초 허용)에서 타협합니다. 도구의 검증 탭에서 ±1 스텝 허용을 켜 보면 같은 동작을 눈으로 확인할 수 있습니다.
import { createHmac, timingSafeEqual } from 'crypto';
function verifyTotp(secret, code, { window = 1, period = 30, digits = 6 } = {}) {
const counter = Math.floor(Date.now() / 1000 / period);
const submitted = Buffer.from(code);
// 시계 오차를 위해 현재 스텝과 ±window 스텝을 확인.
for (let i = -window; i <= window; i++) {
const expected = Buffer.from(totpAt(secret, counter + i, digits));
// 타이밍으로 부분 일치가 새지 않도록 상수 시간 비교.
if (expected.length === submitted.length &&
timingSafeEqual(expected, submitted)) {
return counter + i; // 일치한 스텝 — 재생 공격을 막으려면 저장
}
}
return false;
}
이걸 “동작함”에서 “안전함”으로 끌어올리는 손질이 두 가지 더 있습니다. 첫째는 재생 공격 방지입니다. 사용자마다 마지막으로 받아들인 카운터를 저장해 두고 그 아래 스텝에서 나온 코드를 거부하면, 한 번 가로챈 코드를 같은 윈도 안에서 두 번 쓰지 못합니다. verifyTotp가 그냥 true가 아니라 일치한 스텝을 반환하는 까닭이 여기 있습니다. 둘째는 속도 제한입니다. 6자리 코드는 백만 분의 일이고 ±1 윈도가 매 순간 그중 셋을 유효하게 풀어 주니, 손대지 않으면 공격자가 그 공간을 통째로 무차별 대입합니다. 몇 번 틀리면 계정을 잠그거나 백오프를 거세요. 비밀 키는 오래 살아 있는 키라, 저장할 땐 암호화하고 소스 관리 밖에 두며 비밀번호처럼 다뤄야 합니다. 기기를 잃어버리는 날을 위해 튼튼한 복구 코드도 같이 만들어 두세요.
TOTP가 막는 것, 그리고 막지 못하는 것
TOTP는 비밀번호만 쓰는 것보다 분명히 한 수 위지만 마법은 아닙니다. 마케팅 문구는 그 틈을 슬그머니 덮곤 합니다. 솔직하게 가르면 이렇습니다.
| TOTP가 막는 것 | TOTP가 막지 못하는 것 |
|---|---|
| 유출되거나 재사용된 비밀번호 | 실시간 피싱 / 중간자(adversary-in-the-middle) |
| 자격 증명 스터핑 | 기기에서 비밀 키를 읽어 내는 악성 코드 |
| 원격 비밀번호 무차별 대입 | 2FA를 건너뛰는 허술한 계정 복구 흐름 |
| 비밀번호 해시만 노출된 DB 침해 | (이런 것에는 다른 방어가 필요) |
얻는 것은 큽니다. 이제 로그인에는 비밀 키에서만 나오는 코드가 있어야 하니, 유출된 비밀번호 하나로는 더 이상 통하지 않습니다. 자격 증명 스터핑과 원격 무차별 대입이 한 방에 막힙니다. 데이터베이스가 털리더라도 TOTP 비밀 키를 저장 시 암호화해 뒀다면 공격자는 여전히 코드를 찍어 내지 못합니다.
틈도 그만큼 또렷합니다. 실시간 피싱 프록시(중간자 페이지)는 사용자에게 똑같이 생긴 복제본을 보여 주고 실시간 코드를 낚아채 같은 윈도 안에서 진짜 사이트에 흘려보냅니다. TOTP는 그 코드가 엉뚱한 데 입력됐는지 알지 못합니다. 비밀 키를 빼 가는 기기 내 악성 코드는 TOTP를 통째로 무력화하고, 허술한 “2FA를 잊어버렸어요” 복구 흐름은 아예 옆으로 우회합니다. 자주 헷갈리는 대목 하나는 짚고 넘어가겠습니다. SIM 스와프 공격이 깨뜨리는 건 TOTP가 아니라 SMS 일회용 코드입니다. TOTP에는 전화번호 채널 자체가 없어서 가로채 돌릴 대상이 없습니다.
그럼 다음 단계는 어디일까요. Passkey와 FIDO2/WebAuthn은 출처에 묶여 있어 구조적으로 피싱을 견딥니다. 자격 증명이 엉뚱한 도메인에는 인증을 그냥 거부하기 때문입니다. TOTP는 비밀번호에서 한 칸 올라선 강력하고 어디서나 쓰이는 수단으로 보되, 종착역으로 삼지는 마세요. TOTP는 인증 스택의 나머지와도 무리 없이 맞물립니다. 검증된 로그인 위에 얹는 세션 토큰 계층은 JWT 보안 모범 사례를, 2FA가 받쳐 주는 저장 시 비밀번호 계층은 비밀번호 해싱 (bcrypt vs Argon2)를 보세요.
TOTP를 구현할 때 흔한 함정
TOTP 버그는 대개 RFC로 못 박힌 알고리즘이 아니라 그 둘레의 이음새에서 터집니다. 구현자들이 자주 발목 잡히는 지점은 이렇습니다.
- 서버 시계 오차. 서버에 NTP를 돌리지 않으면 서버가 아는 “지금”이 사용자 기기와 어긋나고, 모두의 코드가 맞지 않게 됩니다. 모든 호스트에서 네트워크 시간 동기화를 켜세요.
- 평문 또는 커밋된 비밀 키. git에 들어간 설정 파일 속 비밀 키는 영영 열려 있는 뒷문입니다. 시크릿 매니저에 암호화해 넣고, 소스 관리에는 절대 두지 마세요.
- 재생 공격 방지 없음. 일치한 스텝을 기록하지 않고 코드를 받아들이면, 같은 코드가 그 윈도 안에서 또 통합니다. 사용자별 마지막 사용 스텝을 영구 저장하고 재사용을 막으세요.
- 너무 넓거나 너무 좁은 윈도. 넓으면 추측 가능한 코드가 늘어 보안이 무뎌지고, 좁으면 사소한 오차에도 정당한 사용자를 내칩니다. 보통은 윈도 1에서 균형을 잡습니다.
- 파라미터 불일치. 등록 쪽
otpauth://URI에는 SHA-256과 8자리가 박혀 있는데 검증기가 SHA-1과 6자리를 가정하면, 어떤 코드도 통과하지 못합니다. URI에서 알고리즘과 자릿수, 주기를 읽어 양쪽이 같이 쓰세요. - 백업이나 복구 코드 없음. 휴대폰을 잃으면 다시 들어갈 길은 복구 경로뿐입니다. 설정할 때 복구 코드를 발급하고, 계정 중요도에 걸맞게 튼튼히 만드세요. 비밀번호 엔트로피에 깔린 논리가 복구용 비밀 값에도 그대로 통합니다.
FAQ
TOTP는 피싱에 안전한가요?
아닙니다. TOTP는 유출된 비밀번호와 원격 무차별 대입은 막지만, 실시간 피싱 프록시는 가짜 로그인을 띄워 실시간 코드를 가로챈 다음 같은 30초 윈도 안에서 진짜 사이트로 넘깁니다. 여기서 한 단계 나은 선택이 Passkey와 FIDO2입니다. 자격 증명을 사이트 출처에 묶어 두기 때문입니다.
TOTP는 SMS 2FA보다 안전한가요?
그렇습니다. SMS 코드는 이동통신 네트워크를 타고 흘러서 SIM 스와프나 SS7 공격으로 가로채일 수 있고, 통신사 보안에 기댑니다. TOTP에는 전화번호 채널이 없는 데다 코드를 아예 보내지 않으니, 전송 중에 빼낼 거리가 없습니다. 비밀 키는 설정할 때 딱 한 번 주고받습니다.
휴대폰이나 인증 앱을 잃어버리면 어떻게 되나요?
미리 만들어 둔 백업이 있어야 합니다. 2FA를 켤 때 저장해 둔 복구 코드, 같은 비밀 키로 등록해 둔 두 번째 기기, 아니면 안전한 곳에 보관한 원본 Base32 비밀 키 중 하나면 됩니다. 이 중 무엇도 없는 채로 기기를 잃으면, 그대로 계정에서 잠겨 나갑니다.
서버는 TOTP 코드를 어떻게 검증하나요?
공유된 비밀 키와 현재 시각으로 기대 코드를 다시 계산한 뒤, 제출된 코드를 현재 시간 스텝과 앞뒤 한 스텝에 맞춰 보며 시계 오차를 흡수합니다. 더해서 같은 코드를 재생하지 못하도록 어느 스텝이 맞았는지 기록하고, 마구잡이 추측을 막으려고 시도 횟수도 묶어 둡니다.
TOTP 코드는 왜 30초마다 새로 바뀌나요?
30초는 RFC 6238 기본 주기입니다. 코드를 느긋하게 읽고 입력할 만큼은 길고, 공격자가 가로챈 코드가 거의 바로 만료될 만큼은 짧습니다. 일부 시스템은 60초 주기를 쓰는데, 검증기가 맞춰 갈 수 있도록 otpauth:// URI에 이 값을 적어 둡니다.
두 기기가 하나의 TOTP 비밀 키를 공유할 수 있나요?
그렇습니다. 알고리즘이 결정론적이라, 시계만 맞으면 같은 Base32 비밀 키를 가진 기기는 어느 것이든 똑같은 코드를 냅니다. 멀티 기기 인증 앱 백업이 이렇게 굴러가고, 동시에 비밀 키를 비공개로 지켜야 하는 까닭이기도 합니다. 그걸 복사하는 사람은 누구든 앞으로의 코드를 전부 찍어 낼 수 있으니까요.
TOTP는 Google Authenticator와 같은 것인가요?
아닙니다. TOTP는 RFC 6238에 정의된 개방형 알고리즘이고, Google Authenticator, Authy, 1Password는 그걸 구현한 앱들입니다. 표준이 같으니 호환되는 앱은 어느 것이든 TOTP를 쓰는 서비스와 맞물립니다. 특정 공급업체에 발이 묶이지 않습니다.
결론
핵심은 머릿속에 담아 둘 만큼 짧습니다.
- TOTP는 비밀 키와 현재 시각을 HMAC과 잘라 내기를 거쳐 코드로 바꿉니다.
- 양쪽이 코드를 따로 계산하고, 코드는 절대 네트워크로 나가지 않습니다.
- ±1 스텝 윈도에 재생 공격 방지와 속도 제한을 얹어 검증하세요.
- 비밀번호 공격은 막지만 실시간 피싱은 못 막습니다. 그 틈은 Passkey가 닫습니다.
- 서버 시계는 NTP로 맞추고, 비밀 키는 암호화해 비공개로 두세요.
알고리즘이 실제 숫자를 뽑는 과정을 직접 보고 검증 윈도를 만져 보고 싶다면, TOTP / 2FA 생성기를 열어 보세요. 비밀 키가 기기를 벗어나지 않은 채, 코드를 계산하고 설정하고 검증하는 일을 전부 브라우저 안에서 끝낼 수 있습니다.