"내 스마트폰에 지문을 등록했는데, 노트북에서 로그인하려면 노트북에 또 등록해야 하나요?"
"스마트폰을 분실하면 계정에는 어떻게 접근하나요?"
기존 FIDO(보안 키 등)는 개인키가 물리적인 단일 기기(Hardware)에 귀속되어 있기 떄문에 백업이나 복제가 불가능하여 보안은 좋지만 사용하기게 쉽지 않았다.
이러한 이슈로 나온 인증 수단이 패스키(Passkey)이다. 패스키는 기존의 FIDO 표준을 그대로 사용하면서 클라우드를 통한 동기화와 아이디 입력 생략(Discoverable Credential)이라는 것이 추가되었다.
Passkey란 무엇인가?
패스키는 "클라우드에 동기화되는 FIDO 개인키" 이다.
애플은 iCloud 키체인을 사용하고 안드로이드는 구글 비밀번호 관리자를 사용하여 패스키(개인키)가 동기화된다. 그래서 아이폰에서 만든 패스키로 맥북이나 아이패드에서도 별도의 등록 없이 바로 사용할 수 있는것이다.
또한 블루투스를 이용한 교차 기기 인증(Cross-Device Authentication)을 지원하고 윈도우 PC 화면에 QR 코드를 아이폰 카메라로 스캔해서 로그인하는 것도 가능하다.
검색 가능한 사용자 인증 정보 Discoverable Credentials
패스키가 기존 FIDO와 코드 레벨에서 달라진 것은 '발견 가능한 자격 증명(Resident Key)'을 필수로 사용하는 것이다.
- 기존 FIDO: 서버가 유저 아이디를 받아 DB를 조회한 뒤, "너 이 ID(Credential ID) 가진 키 있지? 그걸로 서명해!" 하고 목록(allowCredentials)을 줘야만 기기가 작동했기 때문에 아이디를 먼저 입력해야했다.
- 패스키: 기기 내부에 인증 정보뿐만 아니라 '유저 정보(User Handle)'까지 가지고 있기 떄문에 서버가 도메인(RP ID)만 알려주면, 기기에서는 "어? 나 이 사이트 패스키 두 개(개인용, 회사용) 있는데, 어떤 걸로 로그인할래?" 하고 물어보기 때문에 아이디를 입력할 필요가 없다.
패스키 (아이디 없는 로그인) 흐름도

Node.js + Vanilla JS 코드
FIDO 와 패스키의 구현 방법은 프론트엔드에서 navigator.credentials.create와 get을 호출할 때 넘기는 옵션이 조금 달라지는게 전부다. 나머지는 운영체제나 브라우저 API 에서 알아서 다 해준다.
기기 등록 (Registration) - residentKey 속성
패스키를 만들 때는 기기 내부에 유저 정보를 저장하라고 명시해야한다.
// 프론트엔드 (기기 등록 로직)
async function registerPasskey() {
const username = document.getElementById('username').value;
const challengeRes = await fetch('/api/register/challenge', { /* ... */ }).then(r => r.json());
const credential = await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(32), // 서버에서 받은 버퍼
rp: { name: "My Passkey Service", id: "localhost" },
user: {
id: new Uint8Array(16), // User Handle
name: username,
displayName: username
},
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
authenticatorSelection: {
authenticatorAttachment: "platform", // 현재 기기(스마트폰/PC) 사용
userVerification: "required",
// 패스키 생성을 위해 Resident Key(Discoverable Credential) 요구
residentKey: "required"
}
}
});
// ..
}
로그인 (Authentication) - 아이디 없는 로그인
로그인할 때는 유저 아이디를 서버로 보낼 필요가 없고 서버에서 그냥 무작위 챌린지만 받아오면 된다.
// 프론트엔드 (로그인 로직)
async function loginWithPasskey() {
// 1. 유저 아이디 없이 그냥 챌린지만 받아옴
const challengeRes = await fetch('/api/login/challenge_only', { method: 'POST' }).then(r => r.json());
// 2. 브라우저 WebAuthn 호출
const assertion = await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(32), // 서버 챌린지
rpId: "localhost",
userVerification: "required",
// allowCredentials 배열을 아예 주지 않거나 비우면 OS가 알아서 이 도메인에 맞는 패스키 목록을 화면에 띄워준다.
}
});
// 3. 서명된 데이터와 함께 '누가' 로그인했는지 식별자(User Handle)를 서버로 전송
await fetch('/api/login/verify_passkey', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
// 브라우저가 돌려준 유저의 고유 ID (등록할 때 넣었던 user.id 값)
userHandle: bufferToBase64url(assertion.response.userHandle),
signature: bufferToBase64url(assertion.response.signature),
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON)
})
});
}
서버 패스키 검증
서버는 전달받은 userHandle을 통해 이 사용자가 누구인지 찾는다.
// fido-server.js 에 추가될 패스키 로그인 검증 엔드포인트
app.post('/api/login/verify_passkey', (req, res) => {
const { userHandle, signature, clientDataJSON } = req.body;
// 1. 전달받은 userHandle(유저 고유 식별자)로 DB에서 유저와 공개키를 검색합니다.
const user = Object.values(usersDB).find(u => u.handle === userHandle);
if (!user) {
return res.status(404).json({ error: '일치하는 패스키 계정을 찾을 수 없습니다.' });
}
// 2. 찾아낸 공개키로 서명(signature)을 검증
const isValidSignature = true; // (crypto 라이브러리 검증 생략)
if (isValidSignature) {
return res.json({ status: 'ok', message: `${user.name}님, 패스키 로그인 성공` });
} else {
return res.status(401).json({ error: '서명 검증 실패' });
}
});
Conditional UI (자동 완성 로그인)
패스키는 FIDO 와 다르게 입력 폼 자동 완성(Autofill)가 있다.
사용자가 아이디 입력 칸을 클릭하기만 해도 브라우저에 저장된 비밀번호가 나오듯이 navigator.credentials.get()을 호출할 때 mediation: "conditional" 옵션을 추가하면 패스키도 자동 완성 드롭다운에 나온다. 그래서 UX 가 미쳤다.
마무리
라이브러리가 잘 나오면서 개발이 더 쉬워지고 있다.
'임시보관 > 로그인 서비스' 카테고리의 다른 글
| [인증] - FIDO와 WebAuthn의 동작 원리 (0) | 2024.02.26 |
|---|---|
| [인증]: OpenId Connect (OIDC) Protocol And JWTs (0) | 2024.02.26 |
| [인증]: RFC 6749 - The OAuth 2.0 Authorization Framework (0) | 2024.02.26 |