패스키(Passkey)와 WebAuthn 도입 가이드: 인증 흐름 설계부터 비밀번호 없는 로그인 전환 전략까지
핵심 요약
이 글에서 확인할 내용
비밀번호를 없애는 일이 왜 이렇게 오래 걸렸을까 로그인 폼에 아이디와 비밀번호를 입력하는 방식은 인터넷 초기부터 지금까지 거의 변하지 않았습니다. 그동안 우리 프론트엔드 개발자들은 비밀번호 강도 검사, 2단계 인증, CAPTCHA, 로그인 시도 제한 같은 방어막을 겹겹이 쌓아 왔지만, 근본 원인인 "사용자가 매번 같은 비밀 문자열을 타이핑해서 서버에 전송한다"는 구조 자체는 바뀌지 않았습니다
- 1. 비밀번호 인증이 구조적으로 안전할 수 없는 이유
- 2. WebAuthn의 핵심 아이디어: 키 쌍과 챌린지-응답
- 3. 등록(Attestation) 흐름 구현하기
비밀번호를 없애는 일이 왜 이렇게 오래 걸렸을까
로그인 폼에 아이디와 비밀번호를 입력하는 방식은 인터넷 초기부터 지금까지 거의 변하지 않았습니다. 그동안 우리 프론트엔드 개발자들은 비밀번호 강도 검사, 2단계 인증, CAPTCHA, 로그인 시도 제한 같은 방어막을 겹겹이 쌓아 왔지만, 근본 원인인 "사용자가 매번 같은 비밀 문자열을 타이핑해서 서버에 전송한다"는 구조 자체는 바뀌지 않았습니다. 이 구조가 남아 있는 한 피싱과 크리덴셜 스터핑은 사라지지 않습니다.
패스키(Passkey)는 이 구조를 아예 바꿉니다. 사용자는 더 이상 비밀 문자열을 외우거나 입력하지 않고, 기기에 저장된 공개키 암호화 키 쌍으로 서버와 인증합니다. 이 글에서는 WebAuthn이 정의하는 등록(attestation)과 인증(assertion) 흐름을 서버 구현 코드와 함께 살펴보고, platform authenticator와 roaming authenticator의 차이, Conditional UI로 자동완성에 패스키를 노출하는 방법, 기존 비밀번호 시스템에서 점진적으로 전환하는 전략, 그리고 실제 도입 과정에서 자주 부딪히는 함정까지 정리합니다.
1. 비밀번호 인증이 구조적으로 안전할 수 없는 이유
비밀번호 기반 인증의 가장 근본적인 문제는 "같은 비밀"이 사용자의 기억과 서버의 데이터베이스 양쪽에 동시에 존재해야 한다는 점입니다. 이 대칭성 때문에 두 가지 공격이 구조적으로 막히지 않습니다.
첫째는 피싱입니다. 사용자는 눈으로 도메인을 확인하고 로그인 폼에 비밀번호를 입력하는데, 사람의 주의력은 완벽하지 않습니다. 진짜와 거의 동일한 가짜 로그인 페이지에 비밀번호를 입력하면 공격자는 그 값을 그대로 실제 서비스에 재사용할 수 있습니다. 아무리 강력한 비밀번호를 써도 피싱 페이지 앞에서는 무력합니다.
둘째는 크리덴셜 스터핑입니다. 사용자는 여러 서비스에서 같은 비밀번호를 재사용하는 경향이 있고, 어느 한 서비스에서 데이터베이스가 유출되면 공격자는 그 아이디·비밀번호 조합을 다른 서비스에 자동화된 스크립트로 대입합니다. 서비스 자체의 보안이 뚫리지 않아도, 사용자의 다른 계정이 뚫리는 순간 우리 서비스도 함께 위험해집니다.
WebAuthn은 이 두 가지를 구조적으로 차단합니다. 인증에 사용되는 개인키는 기기 밖으로 나가지 않고, 공개키만 서버에 저장됩니다. 그리고 인증 응답은 요청을 보낸 origin에 암호학적으로 묶이기 때문에, 가짜 도메인에서는 애초에 유효한 서명이 생성되지 않습니다. 비밀을 훔칠 수 있는 지점 자체가 사라지는 것이 패스키가 피싱에 강한 근본적인 이유입니다.
2. WebAuthn의 핵심 아이디어: 키 쌍과 챌린지-응답
WebAuthn은 W3C가 표준화한 API로, 2026년 7월 현재 Level 2가 정식 권장 표준(Recommendation)이고 Level 3가 후보 권장안(Candidate Recommendation) 단계에 있습니다(W3C WebAuthn Level 3). 스펙은 등록과 인증 각각을 "ceremony"라고 부르며, 두 단계 모두 서버가 발급한 챌린지(challenge)와 이를 서명한 응답을 주고받는 구조로 이루어집니다.
핵심 개념은 세 가지입니다.
- 비대칭키 쌍: 등록 시점에 authenticator가 서비스별로 새 키 쌍을 생성합니다. 개인키는 authenticator 내부(보안 엔클레이브 또는 동기화된 키체인)를 벗어나지 않고, 공개키만 서버에 전달됩니다.
- 챌린지-응답: 서버는 매 등록·인증 요청마다 무작위 챌린지를 생성합니다. 클라이언트는 이 챌린지를 개인키로 서명해 반환하고, 서버는 저장된 공개키로 서명을 검증합니다. 챌린지가 매번 바뀌기 때문에 응답을 가로채도 재사용(replay)할 수 없습니다.
- Discoverable Credential: 패스키는 자격 증명 ID를 사용자가 직접 입력하지 않아도 authenticator가 스스로 찾아내는 discoverable credential입니다. 이 특성 덕분에 로그인 폼에서 아이디 입력 없이 인증이 가능해지고, Conditional UI 같은 자동완성 경험도 성립합니다.
MDN은 이 API를 "공개키 암호화를 사용해 강력한 인증을 지원하고, 비밀번호 없는 인증과 SMS 없는 다요소 인증(MFA)을 가능하게 하는 Credential Management API의 확장 기능"이라고 설명합니다(MDN Web Authentication API). 같은 문서는 피싱 방어 효과도 별도로 짚는데, 가짜 로그인 사이트를 만든 공격자는 서명이 origin에 따라 달라지기 때문에 사용자로 로그인할 수 없다고 설명합니다. 서버 입장에서 WebAuthn을 구현한다는 것은 결국 "챌린지를 안전하게 발급하고, 클라이언트가 보낸 서명·메타데이터를 스펙이 정한 순서대로 검증하는 일"로 요약됩니다.
3. 등록(Attestation) 흐름 구현하기
등록 ceremony는 크게 네 단계로 진행됩니다. 서버가 옵션(챌린지 포함)을 생성하고, 클라이언트가 navigator.credentials.create()를 호출하고, authenticator가 키 쌍을 만들어 attestation을 반환하고, 서버가 그 응답을 검증해 공개키를 저장합니다.
직접 WebAuthn 스펙을 파싱하고 서명을 검증하는 코드를 작성하는 것은 실수하기 쉬운 영역이라 대부분의 실무 구현은 검증된 라이브러리를 사용합니다. Node.js 환경에서는 @simplewebauthn/server가 널리 쓰입니다.
// server/registration.ts
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import { isoUint8Array } from '@simplewebauthn/server/helpers';
const RP_NAME = 'Waylog';
const RP_ID = 'waylog.dev'; // 등록/인증에서 항상 동일해야 함
const ORIGIN = 'https://waylog.dev';
// 1) 등록 옵션 생성 — 챌린지를 세션(또는 DB)에 임시 저장한다
export async function createRegistrationOptions(user: {
id: string;
email: string;
existingCredentials: { id: string; transports?: AuthenticatorTransport[] }[];
}) {
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userName: user.email,
userID: isoUint8Array.fromUTF8String(user.id),
attestationType: 'none', // 대부분의 컨슈머 서비스는 신원 증명이 불필요
excludeCredentials: user.existingCredentials.map((cred) => ({
id: cred.id,
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: 'required', // discoverable credential = 패스키
userVerification: 'preferred',
},
});
await sessionStore.set(user.id, { currentChallenge: options.challenge });
return options;
}
// 2) 클라이언트가 보낸 attestation 응답 검증
export async function verifyRegistration(userId: string, attestationResponse: unknown) {
const { currentChallenge } = await sessionStore.get(userId);
const verification = await verifyRegistrationResponse({
response: attestationResponse as any,
expectedChallenge: currentChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
});
if (!verification.verified || !verification.registrationInfo) {
throw new Error('Registration verification failed');
}
const { credential } = verification.registrationInfo;
// credential.id, credential.publicKey, credential.counter, credential.transports 를
// 사용자 계정에 연결해 DB에 저장한다
await credentialStore.save(userId, credential);
return { verified: true };
}
여기서 excludeCredentials를 빠뜨리면 같은 기기에서 동일 authenticator로 중복 등록이 가능해지는 사용자 경험 문제가 생기고, expectedOrigin과 expectedRPID를 하드코딩이 아니라 요청 헤더에서 잘못 유추하면 서브도메인 간 rpId 불일치로 검증이 실패하는 경우가 흔합니다. attestationType: 'none'은 대부분의 서비스에 적합한 기본값입니다. 신원이나 기기 제조사 증명이 꼭 필요한 규제 산업이 아니라면 direct attestation은 프라이버시 측면에서 과합니다.
4. 인증(Assertion) 흐름과 Conditional UI
인증 ceremony는 등록과 대칭적인 구조입니다. 서버가 인증용 챌린지를 발급하면 클라이언트가 navigator.credentials.get()을 호출하고, authenticator는 저장된 개인키로 챌린지와 클라이언트 데이터를 서명해 반환합니다.
// server/authentication.ts
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
// 1) 인증 옵션 생성 — 아이디 입력 없이도 진행 가능 (discoverable credential)
export async function createAuthenticationOptions() {
const options = await generateAuthenticationOptions({
rpID: RP_ID,
userVerification: 'preferred',
allowCredentials: [], // 비워두면 브라우저가 등록된 패스키를 스스로 탐색
});
await sessionStore.setAnonymousChallenge(options.challenge);
return options;
}
// 2) assertion 검증
export async function verifyAuthentication(assertionResponse: any) {
const challenge = await sessionStore.getAnonymousChallenge();
const stored = await credentialStore.findByCredentialId(assertionResponse.id);
if (!stored) throw new Error('Unknown credential');
const verification = await verifyAuthenticationResponse({
response: assertionResponse,
expectedChallenge: challenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
credential: {
id: stored.id,
publicKey: stored.publicKey,
counter: stored.counter,
transports: stored.transports,
},
requireUserVerification: true,
});
if (!verification.verified) throw new Error('Authentication failed');
// 새 counter로 갱신 (다음 절 참고)
await credentialStore.updateCounter(stored.id, verification.authenticationInfo.newCounter);
return { userId: stored.userId };
}
Conditional UI는 이 인증 흐름을 로그인 폼의 자동완성 드롭다운에 통합하는 기능입니다. 페이지가 로드되자마자 백그라운드에서 mediation: 'conditional' 옵션으로 인증 요청을 걸어두면, 사용자가 아이디 입력창을 클릭했을 때 브라우저가 저장된 비밀번호와 함께 패스키 옵션을 자동완성 목록에 띄웁니다.
<input type="text" name="username" autocomplete="username webauthn" />
import { startAuthentication } from '@simplewebauthn/browser';
async function initConditionalUI() {
const supported = await PublicKeyCredential.isConditionalMediationAvailable?.();
if (!supported) return;
const optionsJSON = await fetch('/api/webauthn/authentication-options').then((r) => r.json());
// 사용자가 자동완성 목록에서 패스키를 선택할 때까지 대기
const assertion = await startAuthentication({ optionsJSON, useBrowserAutofill: true });
const result = await fetch('/api/webauthn/verify-authentication', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(assertion),
}).then((r) => r.json());
if (result.verified) location.href = '/dashboard';
}
autocomplete 값에 webauthn을 반드시 마지막에 붙여야 브라우저가 이 입력창을 패스키 자동완성 후보로 인식합니다. 이 속성을 빠뜨리면 서버·클라이언트 코드가 모두 정상이어도 자동완성 목록에 패스키가 뜨지 않는, 원인을 찾기 까다로운 문제가 생깁니다.

5. Platform Authenticator vs Roaming Authenticator
WebAuthn authenticator는 기기에 내장되어 있는지, 외부에서 붙이는지에 따라 두 종류로 나뉩니다. 이 차이는 사용자 경험과 계정 복구 전략에 직접 영향을 줍니다.
| 구분 | Platform Authenticator | Roaming Authenticator |
|---|---|---|
| 예시 | Touch ID, Face ID, Windows Hello, Android 지문 센서 | YubiKey 등 FIDO2 보안키 |
| 전송 방식 | 내부(internal) | USB, NFC, BLE, hybrid(QR/caBLE) |
| 동기화 | iCloud Keychain, Google Password Manager 등으로 기기 간 동기화 가능 | 대개 물리적 키 자체에 저장, 동기화 없음(장치 분실 시 재등록 필요) |
| 대표 사용처 | 일반 소비자 서비스의 패스키 로그인 | 고보안 조직의 하드웨어 기반 2단계 인증, 규제 산업 |
| 장점 | 별도 구매 불필요, 생체 인증으로 UX 우수 | 물리적 소유 증명, 키 유출 위험 최소화 |
| 한계 | OS·클라우드 계정 벤더에 종속 | 분실·파손 시 백업키 필요, 휴대 부담 |
패스키라는 용어는 보통 동기화되는(synced) discoverable credential을 가리키며, 대부분 platform authenticator를 통해 생성됩니다. Apple은 iCloud Keychain으로, Google은 Google Password Manager로 여러 기기 간에 동일한 패스키를 공유합니다. Android 14부터는 1Password, Bitwarden, Dashlane 같은 서드파티 비밀번호 관리자도 패스키 공급자(passkey provider) 역할을 할 수 있어, 특정 생태계에 묶이지 않고 패스키를 관리하는 선택지가 늘었습니다.
Roaming authenticator는 반대로 동기화되지 않는 것이 오히려 강점입니다. 개인키가 물리적 키 하드웨어를 벗어나지 않는다는 것이 보안 요구 사항인 조직(금융, 정부, 일부 규제 산업)에서는 여전히 하드웨어 보안키가 표준으로 쓰입니다. 서비스를 설계할 때는 두 종류 모두 navigator.credentials.create() 호출 한 번으로 등록되므로, 서버 코드를 authenticator 종류별로 분기할 필요는 없습니다. 다만 QA 단계에서 반드시 platform authenticator와 roaming authenticator 양쪽으로 테스트해야, 한쪽에서만 나타나는 UX 문제(예: hybrid transport의 QR 코드 스캔 흐름)를 놓치지 않습니다.
6. 점진적 전환 전략: 패스키 우선 + 비밀번호 폴백
이미 수많은 사용자가 비밀번호로 가입한 서비스에서 패스키만 남기고 비밀번호를 강제로 없애는 것은 비현실적입니다. 실무에서 검증된 접근은 두 인증 방식을 당분간 공존시키면서, UX 상으로만 패스키를 우선 노출하는 전략입니다.
| 인증 방식 | 피싱 저항성 | 크리덴셜 스터핑 저항성 | 사용자 부담 | 계정 복구 난이도 |
|---|---|---|---|---|
| 비밀번호 단독 | 낮음 | 낮음 | 기억·입력 필요 | 낮음(이메일 재설정) |
| 비밀번호 + OTP(2FA) | 중간 | 중간 | 매번 코드 확인 | 중간 |
| 패스키(WebAuthn) | 높음 | 높음(재사용 자체가 불가능) | 생체 인증 한 번 | 기기·클라우드 계정 의존 |
전환 단계는 대체로 이런 순서로 진행합니다.
- 로그인 직후 패스키 등록 유도: 비밀번호로 로그인에 성공한 사용자에게 "다음부터는 지문으로 로그인하시겠어요?" 같은 배너를 노출해 등록을 유도합니다. 강제하지 않고 건너뛸 수 있게 합니다.
- 로그인 폼에 Conditional UI 적용: 아이디 입력창에 패스키가 자동완성으로 뜨게 하면, 비밀번호 입력 UI를 그대로 두면서도 패스키를 쓰는 사용자에게는 자연스럽게 더 빠른 경로가 됩니다.
- 비밀번호는 폴백으로 유지: 사용자가 새 기기에서 로그인하거나 패스키가 동기화되지 않은 상황(예: 브라우저 프로필 초기화)을 대비해 비밀번호(또는 이메일 매직 링크) 경로를 당분간 남겨둡니다.
- 비밀번호 없는 신규 가입 옵션 제공: 신규 가입 사용자에게는 처음부터 패스키만으로 계정을 만들 수 있는 옵션을 제시해, 비밀번호 데이터베이스 자체를 늘리지 않는 흐름을 만듭니다.
이 전략의 핵심은 "패스키가 있으면 더 빠르고, 없어도 기존 방식으로 문제없이 로그인된다"는 상태를 오래 유지하는 것입니다. 비밀번호를 완전히 제거하는 결정은 실사용 데이터에서 패스키 등록률과 재로그인 성공률을 충분히 확인한 뒤에 내려도 늦지 않습니다.
7. 계정 복구 문제와 흔한 함정
패스키 도입에서 가장 많이 과소평가되는 부분이 계정 복구입니다. 비밀번호는 이메일 재설정이라는 보편적인 폴백이 있지만, 패스키는 "이 사용자가 정말 본인인가"를 증명할 개인키가 특정 기기(또는 클라우드 계정)에 묶여 있어서 그 기기와 클라우드 계정을 모두 잃으면 복구가 훨씬 까다로워집니다. 실무에서는 대개 다음을 함께 설계합니다.
- 사용자가 패스키를 두 개 이상 등록하도록 유도(주 기기 + 보조 기기, 또는 platform + roaming authenticator 조합)
- 계정별 백업 코드 발급, 또는 이메일/SMS 기반 복구 경로를 최후 수단으로 유지
- iCloud Keychain·Google Password Manager 계정 자체가 탈취되면 그 계정에 동기화된 모든 패스키가 함께 위험해진다는 점을 사용자에게 고지
도입 과정에서 반복적으로 마주치는 함정도 정리해 둘 만합니다.
- counter 검증을 하드 실패 조건으로 처리: WebAuthn 스펙은 authenticator가 인증할 때마다 signature counter를 증가시켜 클론(복제) 공격을 탐지하도록 설계했습니다. 그런데 iCloud Keychain, Google Password Manager 같은 동기화 authenticator는 여러 기기가 하나의 키를 공유하는 구조상 일관된 카운터를 유지하기 어려워 대부분 counter를 항상 0으로 보고합니다. 이는 업계에 이미 잘 알려진 한계입니다(Signature counters, ImperialViolet). 저장된 값과 새 값이 둘 다 0이면 이를 즉시 공격으로 판단해 세션을 끊지 말고, 0이 아닌 값이 감소하거나 정체되는 경우에만 의심 신호로 다뤄야 합니다.
- rpId를 origin에서 잘못 유추:
rpId는 서브도메인을 넘나드는 상위 등록 가능 도메인이어야 하며, 등록과 인증에서 반드시 동일해야 합니다.app.example.com에서 등록하고www.example.com에서 인증을 시도하는 식으로 rpId 설정이 흔들리면 검증이 실패합니다. - excludeCredentials·allowCredentials 누락: 등록 시
excludeCredentials를 채우지 않으면 같은 기기로 중복 등록이 반복되고, 인증 시allowCredentials를 항상 채우면 discoverable credential 기반의 아이디 없는 로그인(Conditional UI 포함)이 동작하지 않습니다. - 하나의 authenticator 종류로만 테스트: platform authenticator에서만 QA하면 hybrid transport(QR 코드로 휴대폰의 패스키를 데스크톱 로그인에 사용하는 흐름)나 roaming authenticator의 PIN 입력 흐름에서 나는 오류를 배포 전에 발견하지 못합니다.
- attestation 검증을 과도하게 엄격히 요구: 일반 소비자 서비스에서
attestationType: 'direct'로 제조사 증명까지 요구하면 일부 패스키 공급자와 호환성 문제가 생길 수 있습니다. 신원 증명이 규제상 필요한 경우가 아니라면none으로 충분합니다.
마치며: 도입 전 점검할 다섯 가지
- 등록·인증 양쪽에서
rpID와origin을 하드코딩된 상수로 관리하고 있는가 excludeCredentials(등록)와 discoverable credential 흐름(인증)이 올바르게 구성되어 있는가- counter가 항상 0인 동기화 패스키를 하드 실패로 처리하지 않는가
- Conditional UI를 위한
autocomplete="username webauthn"이 로그인 폼에 적용되어 있는가 - 비밀번호(또는 다른 폴백 경로)를 당분간 유지하면서 패스키를 우선 노출하는 전환 전략을 세웠는가
패스키는 사용자에게는 더 빠른 로그인을, 서비스에는 피싱과 크리덴셜 스터핑에 대한 구조적인 방어를 동시에 제공합니다. 다만 완전히 새로운 인증 수단을 붙이는 일이기 때문에, 검증 로직 하나하나를 스펙 문서와 라이브러리 문서에 근거해 구현하고, 계정 복구 경로를 비밀번호 시절보다 더 신중하게 설계해야 실제 서비스에서 사고 없이 자리잡을 수 있습니다.
함께 읽으면 좋은 글