패스키(Passkey)는 비밀번호 없이 로그인하는 인증 방식이다. 기기에 저장된 개인키로 서버가 보낸 도전값(challenge)에 서명하고, 서버는 미리 등록해 둔 공개키로 그 서명을 검증한다. 표준 이름은 WebAuthn이고, 브라우저의 navigator.credentials API로 동작한다.
핵심은 "서버에 비밀번호가 저장되지 않는다"는 점이다. 유출될 비밀 자체가 없으니 크리덴셜 스터핑도, 피싱도 원리적으로 막힌다. 나도 처음엔 "지문 로그인 좀 편한 거" 정도로 생각했는데, 실제로 붙여 보니 인증 모델 자체가 다르더라.
왜 지금 패스키인가
2024~2025년을 지나면서 Apple, Google, Microsoft가 전부 OS 레벨에서 패스키를 밀었다. iCloud 키체인과 Google 비밀번호 관리자가 패스키를 기기 간 동기화해 주기 시작하면서, "폰 바꾸면 다시 등록해야 하잖아"라는 오래된 반박이 사실상 사라졌다. 실무에서 도입 장벽이 확 낮아진 게 이 시점이다.
비밀번호 방식과 뭐가 다른지 표로 정리하면 이렇다.
등록과 로그인의 두 단계
WebAuthn은 크게 등록(registration)과 인증(authentication) 두 흐름으로 나뉜다. 둘 다 "서버가 랜덤 challenge를 만들어 보내면, 기기가 서명해서 돌려주고, 서버가 검증한다"는 구조는 같다. 등록 땐 새 키쌍을 만들어 공개키를 저장하고, 인증 땐 그 키로 서명한다.
등록: 공개키를 서버에 저장
서버가 사용자 정보와 challenge가 담긴 옵션을 내려주면, 브라우저에서 이렇게 부른다. challenge는 반드시 서버가 만든 값이어야 하고, 재사용하면 안 된다.
// 클라이언트: 서버 옵션(base64url)을 ArrayBuffer로 바꿔서 호출
const cred = await navigator.credentials.create({
publicKey: {
challenge: base64urlToBuffer(opts.challenge),
rp: { name: 'YoungSam', id: 'youngsam.net' },
user: {
id: base64urlToBuffer(opts.userId),
name: 'me@example.com',
displayName: '샘',
},
pubKeyCredParams: [{ type: 'public-key', alg: -7 }], // ES256
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
},
});
// cred.response 안의 공개키/attestation을 서버로 전송해 저장
residentKey: 'required'가 이른바 "discoverable credential"이다. 이걸 켜야 로그인 화면에서 아이디를 안 치고도 계정을 골라 들어올 수 있다. 패스키의 진짜 편함이 여기서 나온다.
인증: 개인키로 서명
로그인 때는 navigator.credentials.get()을 부른다. 서버는 돌아온 서명을 저장된 공개키로 검증한다. 검증에서 반드시 확인할 세 가지가 있다.
// 서버 검증 시 필수 체크 (의사코드)
assert(clientData.challenge === issuedChallenge); // 내가 발급한 challenge인가
assert(clientData.origin === 'https://youngsam.net'); // 진짜 우리 도메인인가 (피싱 차단 지점)
assert(authData.rpIdHash === sha256('youngsam.net'));
// signCount가 이전보다 커졌는지도 확인 → 복제 탐지
여기서 origin 검증이 패스키가 피싱에 강한 핵심 이유다. 가짜 사이트에서는 origin이 달라 서명이 통과하지 못한다. 사용자가 아무리 속아도 키가 엉뚱한 도메인에 서명을 안 해 준다.
직접 붙이면서 데인 것들
솔직히 처음엔 라이브러리 없이 날로 붙이려다 base64url 인코딩에서 한나절 날렸다. WebAuthn은 값들이 전부 ArrayBuffer라 JSON으로 그냥 못 넘긴다. 서버-클라이언트 사이에 base64url로 직렬화하는 헬퍼를 통일해 두는 게 정신건강에 좋다. 검증 로직은 직접 짜지 말고 @simplewebauthn/server 같은 검증된 라이브러리를 쓰는 걸 강력히 권한다. 서명 검증을 손으로 짜다 실수하면 그게 바로 보안 구멍이다.
두 번째 함정은 계정 복구다. 패스키만 달랑 넣어 두면, 사용자가 기기를 통째로 잃었을 때 들어올 방법이 없다. 그래서 실전에서는 패스키를 "기본", 이메일 매직링크나 백업 코드를 "복구 경로"로 같이 설계한다. 한 계정에 패스키를 여러 개 등록하게 열어 두는 것도 필수다. 나는 데스크톱과 폰 각각 등록을 권하는 온보딩을 넣고 나서야 지원 문의가 줄었다.
세 번째는 userVerification 값이다. required로 두면 지문·PIN 같은 사용자 확인을 강제하는데, 일부 보안키는 이걸 지원 안 해서 등록이 막힌다. 대상 사용자층이 넓다면 preferred가 무난하다. 이 한 줄 때문에 특정 안드로이드 구형 기기에서 등록 실패 리포트가 올라온 적이 있다.
자주 묻는 질문
패스키를 쓰면 비밀번호를 완전히 없애도 되나요?
기술적으로는 가능하고, 그게 지향점입니다. 다만 현실에서는 기기 분실·미지원 브라우저 같은 예외 때문에 이메일 매직링크나 백업 코드 같은 복구 경로를 하나는 남겨 둡니다. 비밀번호를 지우되 다른 복구 수단으로 대체하는 방향이 안전합니다.
패스키는 서버에 무엇을 저장하나요?
공개키와 크리덴셜 ID, 그리고 복제 탐지에 쓰는 서명 카운터 정도입니다. 개인키는 사용자 기기(또는 iCloud·Google 계정)를 절대 벗어나지 않습니다. 그래서 서버 DB가 통째로 털려도 그 데이터만으로는 로그인을 위조할 수 없습니다.
폰을 바꾸면 패스키가 사라지나요?
동기화형 패스키라면 iCloud 키체인이나 Google 비밀번호 관리자가 새 기기로 옮겨 줍니다. 보안키(하드웨어) 같은 기기 고정형은 옮겨지지 않으므로, 이 경우엔 새 기기에서 패스키를 추가 등록하는 흐름을 준비해 둬야 합니다.
WebAuthn과 FIDO2, 패스키는 뭐가 다른가요?
FIDO2는 표준 묶음의 이름이고, 그 안에서 브라우저가 쓰는 웹 API가 WebAuthn입니다. 패스키는 그 기술로 만든 크리덴셜을 사용자 친화적으로 부르는 마케팅·제품 용어예요. 구현자 입장에서는 결국 WebAuthn API를 다룬다고 보면 됩니다.

댓글 0