TOTP와 패스키 2단계 인증: 관리자 로그인에 직접 붙여 본 기록
비밀번호 하나로 지키던 관리자 로그인에 TOTP, 신뢰 브라우저, 복구 코드, 패스키를 붙였습니다. 네 수단은 막는 공격이 다르고, 서로 어떻게 맞물리게 할지 정할 것도 생각보다 많았습니다. 이 글은 구현하면서 정한 기준과 중간에 바로잡은 설계의 기록입니다.
비밀번호가 맞으면 끝나던 구조
막는 장치가 없지는 않았습니다. IP별·계정별 시도 횟수 제한이 있었고, 관리자가 로그인할 때마다 알림이 왔습니다. 세션은 짧게 두었고, "모든 기기에서 로그아웃"을 누르면 그 이전에 발급된 토큰을 전부 무효로 보는 기준선도 있었습니다. 관리자 화면은 공개 사이트와 다른 오리진에 두어서 블로그나 광고 스크립트가 관리자 쿠키를 이용하지 못하게 해 두었습니다.
그런데 하나씩 따져 보니 전부 비밀번호가 안전하다는 전제 위에 서 있었습니다. 다른 서비스에서 샌 비밀번호를 그대로 가져와 한 번에 맞히면, 횟수 제한은 걸리지 않고 로그인은 정상으로 처리됩니다. 알림은 그 사실을 알려 줄 뿐 막지는 못합니다. 예방이 아니라 탐지였던 셈입니다.
라이브러리 없이 30줄이면 되는 TOTP 계산
TOTP(RFC 6238)는 HOTP(RFC 4226)의 카운터 자리에 "지금이 몇 번째 30초인가"를 넣은 것입니다. 서버와 휴대폰이 같은 비밀키를 나눠 갖고, 각자 HMAC-SHA1(비밀키, 30초 스텝 번호)를 계산한 뒤 결과에서 4바이트를 잘라 10⁶으로 나눈 나머지를 씁니다. 통신은 필요 없고 시계만 맞으면 됩니다.
계산이 이게 전부라서 라이브러리를 들이지 않고 node:crypto로 직접 짰습니다. 아이폰 기본 암호 앱이 읽는 기본값(SHA1, 6자리, 30초)만 지원합니다. 다른 값은 QR에 담긴 등록 정보에 적어도 앱마다 무시하는 경우가 있어서 맞추지 않았습니다.
검증은 RFC 부록에 있는 시험 벡터로 했습니다. 여기서 실수가 하나 있었습니다. RFC 4226의 표를 테스트로 옮겨 적으면서 마지막 두 값의 순서를 바꿔 적었고, 테스트가 expected 520489, actual 399871로 실패했습니다. 구현이 아니라 제가 옮겨 적은 표가 틀린 것이었습니다. 직접 짠 암호 코드에 공식 벡터 테스트를 꼭 붙여 두는 이유가 이것입니다. 구현과 기대값 중 어느 쪽이 틀렸는지를 바로 가려 줍니다.
6자리라는 것은 경우의 수가 100만 개뿐이라는 뜻입니다. 그래서 코드 시도 횟수는 비밀번호 시도와 따로 셉니다. 로그인할 때든 설정을 바꿀 때든 코드 시도는 모두 한곳에서 합쳐 셉니다. 같은 30초 안에 같은 코드를 두 번 받지 않도록 마지막으로 받아들인 스텝 번호도 저장합니다. 이 값은 "지금 값보다 클 때만 갱신"하는 조건부 업데이트로 기록해서, 같은 코드를 실은 요청 두 개가 동시에 와도 하나만 통과합니다.
해시로 저장할 수 없는 TOTP 비밀키
비밀번호는 서버가 해시만 알면 됩니다. 입력을 같은 방식으로 해시해서 비교하면 되기 때문입니다. TOTP는 다릅니다. 서버도 휴대폰과 같은 코드를 계산해야 하므로 비밀키 원문이 필요합니다.
그래서 서버 쪽 키로 AES-256-GCM 암호화를 해서 보관했습니다. 암호화할 때 사용자 ID를 추가 인증 데이터(AAD)로 넣어서, 암호문을 다른 사용자 계정에 옮겨 붙이면 풀리지 않게 했습니다. 이 방식이 막는 것은 "DB 사본만 새는 경우"입니다. 백업 파일이 유출되는 식입니다. DB와 서버 환경변수가 함께 새면 비밀키도 함께 샙니다. 다만 그 경우에는 세션 서명 키도 이미 샌 상태라, 여기서 더 막을 수 있는 것이 없다고 판단했습니다.
등록 과정에서 하나 더 정한 것이 있습니다. 새 비밀키는 QR로 보여 준 직후 저장하지 않습니다. 암호화한 채로 화면에 돌려보냈다가, 앱이 만든 코드를 한 번 맞혀야 비로소 저장합니다. 앱에 잘못 등록한 채로 2단계가 켜져서 스스로 잠기는 일을 막기 위해서입니다.
매번 코드를 묻지 않게 하는 신뢰 브라우저
매번 6자리를 넣는 것은 금방 귀찮아집니다. 그래서 코드를 맞힌 브라우저에 "30일간 기억하기"를 붙였습니다. 쿠키에는 무작위 32바이트 토큰을 넣고, DB에는 그 SHA-256 해시만 둡니다. 쿠키는 __Host- 접두사(호스트 전용), HttpOnly(스크립트가 못 읽음), SameSite=Strict로 발급합니다.
중요한 것은 이 쿠키가 코드만 건너뛰게 해 준다는 점입니다. 비밀번호는 여전히 묻습니다. 그래서 쿠키 하나를 훔쳐서는 들어올 수 없습니다. DB에 기록이 남으므로 브라우저를 하나씩 해제할 수 있고, 비밀번호를 바꾸거나 "모든 기기에서 로그아웃"을 누르면 신뢰 브라우저도 전부 해제합니다. 둘 다 유출이 의심될 때 누르는 버튼이기 때문입니다.
TOTP로는 막지 못하는 실시간 피싱
TOTP에는 분명한 한계가 있습니다. 6자리 코드는 사람이 보고 옮겨 적는 값입니다. 진짜와 똑같이 생긴 가짜 로그인 페이지가 비밀번호와 코드를 받아 30초 안에 진짜 사이트로 넘기면 그대로 뚫립니다. 사람이 속으면 끝입니다.
패스키(WebAuthn)는 이 지점에서 다릅니다. 브라우저가 지금 열린 페이지의 오리진을 서명할 데이터에 넣고, 기기는 등록할 때의 도메인(RP ID)이 아니면 키를 아예 꺼내지 않습니다. 사람이 속아도 기계가 거절합니다. 그래서 패스키 로그인에는 비밀번호도 코드도 묻지 않게 했습니다. 기기를 가지고 있다는 것과 Face ID를 통과했다는 것이 이미 두 가지 요소이기 때문입니다.
정리하면 네 수단은 막는 것이 이렇게 다릅니다.
| 수단 | 유출된 비밀번호 | 실시간 피싱 | 휴대폰 분실 시 |
|---|---|---|---|
| 비밀번호만 | 못 막음 | 못 막음 | 해당 없음 |
| 비밀번호 + TOTP | 막음 | 못 막음 | 복구 코드로 들어옴 |
| 신뢰 브라우저 | 막음(비밀번호는 여전히 필요) | TOTP와 같음 | 그 브라우저에서는 계속 로그인됨 |
| 패스키 | 해당 없음(비밀번호를 안 씀) | 막음 | iCloud 키체인에서 복원 |
패스키는 2단계(TOTP)가 켜진 계정만 등록할 수 있게 했습니다. 패스키를 등록해 놓고 비밀번호만으로 들어오는 길이 옆에 그대로 열려 있으면 패스키를 둔 의미가 없기 때문입니다.
서명 카운터가 늘 0인 동기화 패스키
WebAuthn에는 서명 카운터가 있습니다. 기기가 서명할 때마다 숫자를 올리고, 서버는 숫자가 줄어들면 키가 복제됐다고 의심합니다. 그런데 iCloud 키체인처럼 여러 기기에 동기화되는 패스키는 이 값을 늘 0으로 보냅니다. 카운터로는 가로챈 서명을 다시 보내는 것(재전송)을 막을 수 없다는 뜻입니다.
그래서 챌린지를 DB에 저장하고, 쓰는 순간 지우게 했습니다. 지우기에 성공한 요청 하나만 통과하는 조건부 삭제입니다. 서버리스 환경이라 요청이 어느 인스턴스로 갈지 모르므로 메모리에 두지 않았습니다. Face ID 같은 사용자 확인도 필수로 걸었습니다. 이 확인이 없으면 기기를 손에 넣은 사람은 누구나 서명을 만들 수 있습니다.
서버 쪽 검증은 @simplewebauthn/server v14를 썼습니다. CBOR 해석과 COSE 키 검증까지 직접 짜는 것은 TOTP 30줄과 차원이 다릅니다. 설치에서 바로 막혔습니다. next-auth가 자체 패스키 기능용으로 이 라이브러리의 v9를 optional peer로 걸어 두어서 npm install이 ERESOLVE로 멈췄습니다. 그 기능은 쓰지 않으므로 overrides로 범위를 맞췄습니다. 예전에 서버리스 런타임에서 ESM 전용 패키지를 require하다 배포가 통째로 죽은 일이 있어서, 빌드 결과물에서 이 라이브러리가 외부 참조로 남지 않고 번들 안에 들어갔는지도 확인했습니다.
휴대폰 없이 패스키를 시험한 소프트웨어 인증기
패스키 로그인을 바꿀 때마다 휴대폰을 들고 Face ID를 할 수는 없었습니다. 그래서 테스트용으로 Node에서 인증기를 흉내 냈습니다. P-256 키쌍을 만들고, 기기가 보내는 것과 같은 모양의 CBOR 등록 데이터와 ECDSA 서명을 직접 조립했습니다. 서명 검증은 실제 라이브러리가 그대로 하므로, 이 가짜 인증기가 통과하면 형식이 맞다는 뜻입니다.
이걸로 21개 시나리오를 돌렸습니다. 같은 서명을 다시 보내면 거절되는지, 서명을 1바이트 바꾸면 거절되는지, 가짜 도메인에서 만든 서명이 거절되는지를 확인했습니다. TOTP와 신뢰 브라우저 쪽은 일회용 Postgres와 프로덕션 빌드 위에서 실제 HTTP 요청으로 25개를 돌렸습니다.
테스트가 실패한 이유 중 둘은 오히려 반가운 쪽이었습니다. 원래 있던 IP별 로그인 횟수 제한에 테스트 스크립트가 걸렸습니다. 패스키 테스트에서는 이번에 새로 넣은 "비밀번호 재확인 횟수 제한"이 여섯 번째 확인을 막았습니다. 막혀야 할 것이 제대로 막혀서 테스트가 실패한 것이라, 테스트 쪽을 나눠서 다시 짰습니다.
등록과 사용을 나눈 두 번째 설계
처음 만든 "2단계 인증 끄기"는 비밀키, 복구 코드, 신뢰 브라우저, 패스키를 전부 지웠습니다. 다시 켜려면 QR을 다시 스캔하고 Face ID도 다시 등록해야 했습니다. 잠깐 끄고 싶을 때 치르는 대가가 너무 컸습니다. 실제로 써 보기도 전에 "껐다 켤 수는 없나"라는 질문이 먼저 나왔습니다.
그래서 등록과 사용을 나눴습니다. 인증 코드와 패스키 로그인에 각각 스위치를 두고, 꺼도 등록은 남깁니다. 다시 켜면 아이폰 암호 앱에 있던 예전 항목이 그대로 맞습니다. 전부 지우는 것은 "초기화"라는 별도 버튼으로 남겼습니다. 휴대폰을 바꿨거나 비밀키가 노출됐을 때만 쓰는 버튼입니다.
스위치를 만들면서 규칙 세 가지를 정했습니다. 켜는 것은 비밀번호만 확인하고, 끄는 것은 비밀번호와 코드를 함께 확인합니다. 열린 세션을 잠깐 빌린 사람이 2단계를 꺼 버리지 못하게 하려는 것입니다. 보안이 강해지는 쪽은 쉽게, 약해지는 쪽은 어렵게 했습니다. 다음으로, 인증 코드를 끄면 패스키 로그인도 함께 멈춥니다. 패스키만 켜 두면 비밀번호만으로 들어오는 길이 다시 열리기 때문입니다. 패스키 스위치 값은 그대로 두어서 인증 코드를 다시 켜면 원래대로 돌아옵니다. 마지막으로, 인증 코드를 끌 때 신뢰 브라우저는 해제합니다. 나중에 다시 켰을 때 예전에 신뢰했던 브라우저가 코드 없이 들어오면 안 되기 때문입니다.
복구 코드를 관리자 화면 안에 보관할 때의 순환
복구 코드를 발급하는 화면에 처음에는 "볼트 같은 안전한 곳에 보관하세요"라고 적었습니다. 그런데 이 관리자 화면 안에는 프로젝트별 비밀값을 보관하는 기능이 따로 있습니다. 문구만 보면 거기에 넣으라는 뜻으로 읽힙니다.
거기에 넣으면 순환이 생깁니다. 휴대폰을 잃어버리면 로그인하려고 복구 코드가 필요하고, 복구 코드는 관리자 화면 안에 있고, 관리자 화면을 열려면 로그인이 필요합니다. 그래서 문구를 "관리자 화면 바깥(비밀번호 관리자, 인쇄본)에 보관하세요"로 바꿨습니다. 비밀키를 암호화하는 서버 키도 같은 이유로 외부 비밀 저장소에 정본을 두고, 배포 환경에는 사본만 둡니다.
복구 코드 자체는 16자(80비트)로 만들었습니다. 해시를 SHA-256 한 번만 해도, 유출된 해시에서 하나씩 대입해 원래 코드를 찾아내기에는 너무 긴 길이입니다. 한 번 쓰면 다시 쓸 수 없고, 쓸 때마다 알림이 옵니다.
아직 정하지 못한 비밀번호 + TOTP 경로
배포한 뒤 실제 아이폰으로 확인했습니다. 암호 앱에 TOTP를 등록해 코드로 로그인하는 것, Face ID로 패스키를 등록하고 비밀번호 없이 패스키로 로그인하는 것까지 모두 동작했습니다. 다만 Apple 기기 밖에서는 아직 시험하지 않았습니다. 안드로이드나 Windows Hello에서 패스키가 어떻게 보이는지는 모릅니다.
남은 고민은 하나입니다. 패스키가 있어도 비밀번호 + TOTP 경로는 그대로 열려 있고, 이 경로는 앞의 표에서 보듯 실시간 피싱에 약합니다. 패스키가 없는 기기를 위한 길이라 지금은 남겨 두었습니다. 그런데 지금 스위치로는 이 경로만 따로 끌 수 없습니다. 인증 코드를 끄면 패스키도 함께 멈추게 만들었기 때문입니다. 패스키만으로 충분하다는 확신이 들면, "패스키 전용" 같은 선택지를 하나 더 만들어야 합니다.
함께 읽기
- ChainDrop npm 공격 점검기: lock 파일 43개에서 한 버전 차이로 비켜 간 감염ChainDrop이라는 npm 공급망 공격을 알게 된 것은 공격이 일어나고 거의 두 달이 지난 9월 30일이었습니다. O'Reilly의 9월 트렌드 정리를 읽다가 "자기 복제하는 악성코드가 npm 패키지 1,300개 이상을 감염시켰고, 출처 증명까지 정상이었다"는 한 줄을 봤습니다. 감염 패키지 이름에 flat-cach…
- X-Forwarded-For 신뢰 경계: 덧붙이는 프록시와 덮어쓰는 플랫폼요청 제한을 분당 30회로 걸어 두었습니다. 접근 로그에는 같은 초에 수백 건이 찍혀 있는데 429는 한 건도 없습니다.
- 업무 시스템 보안 단계적 도입: 1단계 기준선과 나중에 붙일 것첫 고객의 업무 시스템을 만들기 시작하면 보안 항목 목록이 금세 길어집니다. MFA, 세션, 감사 로그, 백업, 접근 통제, SSO, 기기 인증, 로그 중앙화, 전용 네트워크. 목록을 다 채우고 시작하려 하면 업무 기능을 만들기 전에 인프라 설정으로 몇 주가 지나갑니다.
- 업무 시스템 보안 구성: MFA, 세션, WAF, RBAC, RLS사내 업무 시스템을 만들 때 자주 나오는 질문이 있습니다. 「앞에 Cloudflare Access 같은 걸 두면 보안은 끝나는 것 아닌가요?」
- Next.js 보안 헤더와 색인 차단: 새 프로젝트마다 같은 설정을 두는 이유Vercel 로 옮기고 나서 응답 헤더를 다시 봤더니 보안 헤더가 하나도 없었습니다. 예전에는 오리진 앞의 nginx 가 붙여 주고 있었는데, 그 nginx 가 경로에서 빠지면서 전 경로에서 같이 사라진 것입니다. 앱은 그대로였고 오류도 없었습니다. 헤더는 없어져도 화면이 멀쩡해서, 재보기 전에는 모릅니다.