Passkey는 왜 엔지니어인 우리조차 헷갈리는가: FIDO2/WebAuthn 실무 도입기
어젯밤 HSA 사이트에서 로그인하다가 갑자기 "패스키를 설정하시겠어요?" 팝업이 떴다. 무심코 눌렀다가 회사 노트북의 비밀번호 관리자에 패스키가 등록됐고, 오늘 아침 개인 컴퓨터에서 그 계정에 접근할 수 없게 됐다. 이건 원문 Hacker News 댓글에 나온 실제 사례인데, 나도 비슷한 경험이 있다. 5년째 인프라/DevOps를 하면서 SSH 키, TPM, YubiKey를 매일 다루는 사람인데도 Passkey는 "이게 대체 어디 저장되는 거지?"라는 질문에 즉답을 못 한다.
이번 글은 X 프로덕트 헤드 Nikita Bier의 "Passkey는 소비자 심리를 이해 못 하는 엔지니어들이 만들었다"는 비판을 출발점으로 삼는다. 하지만 단순히 "UX가 나쁘다"고 끝내지 않고, 백엔드/인프라 엔지니어가 실제로 로그인 서비스에 Passkey를 붙일 때 무엇을 알아야 하는지, 특히 디바이스 분실·복구·계정 공유 같은 지뢰밭을 어떻게 설계해야 하는지를 다룬다.
1. 도입: 왜 지금 Passkey가 다시 도마에 올랐나
Passkey는 새로운 기술이 아니다. FIDO2/WebAuthn 표준 자체는 몇 년 전부터 있었고, Apple·Google·Microsoft가 밀면서 Amazon, GitHub 같은 대형 서비스가 이미 지원한다. 문제는 표준은 우아한데 사용자 경험이 산산조각 나 있다는 점이다.
원문에서 가장 뼈아픈 지적은 이거다. Hacker News는 컴퓨터 활용 상위 1%에 가까운 사람들이 모인 곳인데, 그곳의 26년차 엔지니어조차 이렇게 말한다:
"공개키·개인키 원리도 이해하지만, 여러 기기와 브라우저에서 패스키를 어떻게 써야 로그인에 지장이 없는지는 모르겠다. iPad, iPhone, Windows, MacBook에서 Brave·Firefox·Safari를 쓰는데, 휴대폰 Safari에서 우연히 패스키를 만들면 다른 기기에서도 되는지, 동기화되는지, 사이트마다 몇 개까지 되는지 불분명하다."
여기서 핵심을 짚자. Passkey의 "개념"이 어려운 게 아니라, "구현이 사이트마다 제각각"이라 멘탈 모델을 세울 수 없다는 것이다. 이건 UX 문제인 동시에, 우리 같은 서비스 구현자가 만들어내는 문제다. 즉 우리가 어떻게 붙이느냐에 따라 사용자 경험이 결정된다는 뜻이다.
2. 핵심: Passkey 동작 원리를 SSH 키로 이해하기
원문 댓글 중 가장 정확한 설명은 이거다: "웹용으로 자동화된 SSH authorized_keys라고 생각하면 된다." 인프라 엔지니어라면 이 비유 하나로 90%가 이해된다.
등록(Registration): 키 쌍을 만든다
SSH를 처음 세팅할 때 우리가 하는 일을 떠올려보자.
$ ssh-keygen -t ed25519 -C "my-laptop"
Generating public/private ed25519 key pair.
Your identification has been saved in ~/.ssh/id_ed25519
Your public key has been saved in ~/.ssh/id_ed25519.pub
# 서버에 공개키만 등록
$ ssh-copy-id user@server
Passkey도 똑같다. 사이트에 가입할 때 브라우저(정확히는 인증기, Authenticator)가 비대칭 키 쌍을 만들고, 공개키만 서버로 보낸다. 개인키는 절대 서버로 가지 않는다. 차이점은 개인키가 파일(~/.ssh/)이 아니라 다음 중 한 곳에 저장된다는 것이다.
- 하드웨어 보안 모듈: YubiKey, 스마트폰의 Secure Enclave, PC의 TPM
- 클라우드 동기화 저장소: iCloud Keychain, Google Password Manager, 1Password, Bitwarden
바로 이 "어디에 저장되느냐"가 사용자를 혼란에 빠뜨리는 지점이다. SSH는 ~/.ssh/에 있다는 게 명확한데, Passkey는 "휴대폰인가? 브라우저인가? 지문인가?" 알 수가 없다. 여기서 중요한 오해 하나를 풀자. 지문/얼굴은 개인키가 아니다. 생체 인증은 그저 기기에 저장된 개인키를 꺼내 쓰기 위한 잠금 해제 수단일 뿐이다. Touch ID는 "너 정말 이 폰 주인 맞아?"를 확인하는 것이고, 실제 인증은 그 뒤 개인키가 한다.
인증(Authentication): 챌린지-응답
로그인할 때는 SSH의 챌린지-응답과 동일하다. 서버가 랜덤한 챌린지를 보내면, 개인키로 서명해서 돌려준다. 서버는 저장해둔 공개키로 서명을 검증한다.
Passkey의 진짜 강점은 여기서 나온다: 피싱 저항성. WebAuthn은 서명할 때 도메인(origin)을 함께 묶어 서명한다. 즉 google.com용 Passkey는 google-login.evil.com에서는 절대 동작하지 않는다. TOTP 코드는 가짜 페이지가 중계(relay)할 수 있지만, Passkey 챌린지-응답은 중계가 원천 불가능하다. 이게 SMS OTP나 TOTP보다 확실히 나은 점이다.
서버는 무엇을 저장하나
백엔드 관점에서 Passkey 사용자 레코드는 대략 이렇게 생겼다. 서버가 저장하는 건 "비밀"이 아니라 "공개키"라는 게 핵심이다.
{
"user_id": "u_8f3a2b",
"credentials": [
{
"credential_id": "AQIDBAUGBwgJCgsMDQ4PEA...",
"public_key": "pQECAyYgASFYI...",
"sign_count": 42,
"transports": ["internal", "hybrid"],
"aaguid": "adce0002-35bc-c60a-648b-0b25f1f05503",
"created_at": "2024-11-20T09:12:00Z",
"device_name": "iPhone 15 (iCloud)"
}
]
}
여기서 실무적으로 중요한 두 가지:
credentials가 배열이라는 점. 한 사용자가 여러 Passkey를 등록할 수 있고, 반드시 그렇게 설계해야 한다(뒤에서 설명).sign_count는 복제 탐지용 카운터다. 하지만 iCloud처럼 클라우드 동기화되는 Passkey는 카운터가 0으로 고정되거나 증가하지 않는 경우가 많다. sign_count가 0이라고 무조건 거부하면 안 된다. 이건 흔한 함정이라 뒤에서 다시 언급한다.
3. 실무 관점: 도입 시 고려사항과 지뢰밭
3-1. 절대 "Passkey 온리"로 강제하지 마라
원문에서 가장 많이 나온 불만이 디바이스 분실 = 계정 접근 상실이다. 이건 UX 문제가 아니라 아키텍처 설계 문제다. 비밀번호는 종이에 적어 금고에 넣을 수 있고, TOTP는 여러 기기에 설치할 수 있다. 그런데 Passkey를 유일한 인증 수단으로 두고 그게 하드웨어에만 있으면, 기기 분실 시 복구가 악몽이 된다.
xguru의 댓글에 나온 BeeBS의 접근이 현실적인 정답에 가깝다:
"먼저 이메일 매직링크로 가입한 뒤 Passkey를 등록하도록 했다. 이 조합이면 서버에 비밀번호를 전혀 저장하지 않아도 되고, 기기를 잃어도 이메일로 다시 로그인해 새 Passkey를 등록할 수 있다."
실무 설계 원칙을 정리하면:
- Passkey는 "빠른 로그인 수단"으로 붙이고, 복구 경로는 항상 별도로 확보한다. 이메일 매직링크, 백업 코드, 또는 두 번째 Passkey.
- 등록 UX에서 "두 개 이상 등록"을 강하게 유도한다. "이 기기 + 다른 기기(또는 YubiKey)". 원문의 78세 부모님 사례처럼 "예비 열쇠도 두자"는 비유가 실제로 통했다.
3-2. 흔한 함정: 실제로 마주칠 에러들
(a) origin 불일치 에러 — 개발 환경에서 가장 먼저 만난다. 로컬에서 localhost로 테스트하다가 스테이징 도메인으로 넘어갈 때, 혹은 RP ID(Relying Party ID) 설정을 잘못하면 브라우저 콘솔에 이게 뜬다:
SecurityError: The relying party ID is not a registrable domain
suffix of, nor equal to the current domain.
이건 rpId를 현재 접속한 도메인과 맞지 않게 설정했을 때 발생한다. 예를 들어 app.example.com에서 접속했는데 rpId를 login.other.com으로 줬거나, localhost가 아닌 IP(127.0.0.1)로 접속한 경우다. WebAuthn은 localhost는 예외적으로 허용하지만 IP 주소는 허용하지 않는다는 걸 기억하자.
(b) sign_count 검증 실패 — 위에서 언급한 문제. iCloud 동기화 Passkey로 로그인했는데 서버가 이렇게 거부하는 경우:
AuthenticationError: signature counter did not increase
(stored: 42, received: 0) — possible cloned authenticator
이론적으로는 복제 탐지 로직이 맞지만, 클라우드 동기화 Passkey는 카운터가 0으로 오는 게 정상 동작이다. sign_count가 0이면 카운터 검증을 스킵하도록 예외 처리해야 한다. 이걸 모르고 엄격하게 막으면 Apple 사용자 전체가 로그인 불가에 빠진다. (관련 동작은 사용하는 라이브러리 문서 확인 필요 — SimpleWebAuthn, WebAuthn4J 등마다 기본값이 다르다.)
(c) 사용자 검증(User Verification) 정책 충돌 — 등록 시 userVerification: "required"로 했는데 하드웨어 키가 PIN을 지원하지 않으면:
NotAllowedError: The operation either timed out or was not allowed.
이 에러는 원인 범위가 넓어서 악명 높다. 사용자가 그냥 취소했을 수도 있고, UV 정책이 안 맞을 수도 있고, 타임아웃일 수도 있다. 로그만 보고는 원인을 특정하기 어려우니, 클라이언트에서 등록/인증 옵션을 상세 로깅해두는 게 디버깅에 필수다.
3-3. 계정 공유는 근본적으로 깨진다
원문에서 반복적으로 나오는 현실 문제. "Netflix 비밀번호 보내줄래?"가 안 된다. 비밀번호는 문자로 보낼 수 있지만 Passkey는 특정 기기/클라우드에 묶여 있어 남에게 넘길 수 없다. 배우자와 Amazon Prime을 공유하는 흔한 시나리오조차 막힌다.
실무적으로는 이렇게 접근해야 한다:
- 가족/팀 공유가 핵심 유스케이스인 서비스라면, 계정 공유를 Passkey로 풀지 말고 "초대/멤버 권한" 같은 제대로 된 멀티유저 모델로 설계하라. Passkey는 개인 인증 수단이지 공유 수단이 아니다.
- 공유가 불가피하면 비밀번호 로그인 경로를 함께 남겨둔다.
3-4. 트레이드오프 요약
| 항목 | Passkey | 비밀번호 + 관리자 |
|---|---|---|
| 피싱 저항성 | 강함 (origin 바인딩) | 약함 (사람이 붙여넣기 가능) |
| 서버 유출 시 | 공개키만 유출 → 안전 | 해시라도 크래킹 위험 |
| 기기 분실 복구 | 어려움 (설계 필수) | 비교적 쉬움 |
| 계정 공유 | 사실상 불가 | 가능 (보안상 비권장이지만) |
| 구현 일관성 | 사이트마다 제각각 | 성숙, 예측 가능 |
| 벤더 종속 | 클라우드 동기화 시 종속 우려 | 낮음 |
원문의 균형 잡힌 결론 하나를 인용하자면: "비밀번호는 근본적으로 망가진 보안 모델이고, 사용자들도 제대로 쓴 적이 거의 없다. 새 방식이 어렵다는 이유로 쉬운 문제에만 머무르는 건 바람직하지 않다." 즉 Passkey를 무작정 배격할 이유는 없지만, 복구·공유·일관성 문제를 우리가 설계로 메꿔야 한다는 뜻이다.
4. 정리: 한 줄 요약과 도입 체크리스트
한 줄 요약: Passkey는 SSH 키를 웹에 자동화한 것이고, 개념은 우아하지만 "복구·공유·일관성"을 서비스 구현자가 책임지고 설계하지 않으면 사용자에게는 "마법의 요정 가루"로 남는다.
누가 언제 써야 하나:
- 써라: 피싱이 실제 위협인 서비스(금융, 관리자 콘솔), Apple/Google 생태계에 익숙한 일반 소비자 대상 서비스. 특히 "모든 사이트에서 같은 비밀번호를 직접 입력하던" 사용자를 대체할 때 보안 이득이 크다.
- 미뤄라 / 병행하라: 계정 공유가 핵심인 서비스, 오프라인 백업/자체 동기화를 중시하는 기술직 사용자 비중이 높은 경우, 다양한 OS/브라우저 조합을 지원해야 하는 경우.
도입 체크리스트:
- Passkey를 유일 인증 수단으로 강제하지 않는다. 복구 경로(이메일 매직링크/백업 코드) 필수.
- 한 사용자당 여러 credential 등록을 지원하고, 등록 시 두 번째 키를 유도한다.
sign_count == 0케이스(클라우드 동기화 Passkey)를 예외 처리한다.rpId와 origin 설정을 환경별(local/staging/prod)로 명확히 분리한다.- 계정 공유는 Passkey가 아닌 멀티유저 권한 모델로 푼다.
- 사용자에게 "이 폰이 열쇠이고, 예비 열쇠도 만들어두자"는 물리적 열쇠 비유로 안내한다. 원문 사례상 이게 가장 잘 통했다.
- 등록/인증 옵션을 상세 로깅해
NotAllowedError같은 모호한 에러를 디버깅할 수 있게 한다.
참고 자료
- Passkey는 소비자 심리를 전혀 이해하지 못한 엔지니어들이 만들었다 (GeekNews 원문)
- WebAuthn Guide — 동작 원리 개괄
- W3C Web Authentication (WebAuthn) 표준 스펙
- FIDO Alliance — Passkeys 공식 소개
- SimpleWebAuthn — Node.js 구현 라이브러리 (sign_count 처리 등 참고)
※ 본문의 에러 메시지와 코드는 실무에서 마주치는 형태를 재구성한 예시다. 라이브러리·OS·브라우저 버전에 따라 문구와 기본 동작이 다를 수 있으니, 실제 도입 시 사용하는 스택의 공식 문서를 확인하기 바란다.