- Published on
Passkeys 엔터프라이즈 도입 가이드 — WebAuthn/FIDO2부터 Keycloak 통합까지
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 비밀번호의 종말 2026
- WebAuthn/FIDO2 아키텍처 한눈에 보기
- Registration Ceremony — 등록 의식 상세
- Authentication Ceremony — 로그인 의식 상세
- Synced Passkey vs Device-bound — 무엇이 다른가
- 피싱 저항성의 원리 — Origin Binding
- Keycloak 26 Passkeys 통합
- 프론트엔드 코드 예제 — 직접 RP를 구현하는 경우
- 점진적 롤아웃 전략 — MFA에서 Passwordless로
- 계정 복구 — Passwordless의 아킬레스건
- 기업 정책 — Attestation 검증과 AAGUID 허용 목록
- 도입 시 흔한 실수 (안티패턴)
- 마치며
- 참고 자료
들어가며 — 비밀번호의 종말 2026
2026년 현재, 비밀번호는 명실상부한 "레거시 인증 수단"이 되었습니다. FIDO Alliance에 따르면 주요 컨슈머 서비스(Google, Apple, Microsoft, Amazon, TikTok 등)의 passkey 지원이 보편화되었고, 수십억 개의 계정이 이미 passkey를 등록했습니다. Verizon DBIR 보고서가 매년 반복해서 지적하듯, 침해 사고의 대다수는 여전히 탈취된 크리덴셜(stolen credentials)과 피싱에서 시작됩니다. 비밀번호가 존재하는 한 피싱은 사라지지 않습니다.
엔터프라이즈 환경의 셈법은 컨슈머와 조금 다릅니다. 단순히 "로그인이 편해진다"가 아니라 다음 세 가지가 핵심 동인입니다.
- 피싱 저항성(phishing resistance): 미국 OMB의 Zero Trust 전략(M-22-09)이 연방 기관에 피싱 저항 MFA를 의무화한 이후, 금융/의료/공공 섹터의 규제가 같은 방향으로 수렴하고 있습니다. OTP, 푸시 알림 MFA는 중간자 피싱 키트(Evilginx 류)에 무력하다는 것이 실증되었습니다.
- 헬프데스크 비용: 비밀번호 초기화는 헬프데스크 티켓의 20~50%를 차지하는 고전적인 비용 항목입니다. passwordless 전환은 이 비용을 구조적으로 제거합니다.
- 사용자 경험과 전환율: 로그인 성공률 상승, 로그인 시간 단축은 내부 직원 생산성과 고객 전환율 모두에 직접적인 영향을 줍니다.
이 글에서는 WebAuthn/FIDO2 프로토콜의 내부 동작을 먼저 해부하고, Keycloak 26의 passkeys 통합 설정, 점진적 롤아웃 전략, 그리고 엔터프라이즈에서 반드시 부딪히는 계정 복구와 attestation 정책 문제까지 실전 관점에서 다룹니다.
WebAuthn/FIDO2 아키텍처 한눈에 보기
FIDO2는 두 개의 스펙으로 구성됩니다.
- WebAuthn (W3C): 브라우저/플랫폼이 웹 애플리케이션에 노출하는 JavaScript API와 데이터 구조를 정의합니다. 현재 WebAuthn Level 3이 최신입니다.
- CTAP2 (FIDO Alliance): 클라이언트(브라우저/OS)와 외부 인증기(보안 키, 스마트폰) 사이의 통신 프로토콜입니다.
전체 구성 요소의 관계는 다음과 같습니다.
+------------------+ +---------------------+ +------------------+
| Relying Party | | Client/Platform | | Authenticator |
| (웹 서버, | <----> | (브라우저 + OS) | <----> | (Touch ID, |
| Keycloak 등) | HTTPS | | CTAP2 | YubiKey, |
| | | WebAuthn API | 또는 | 스마트폰) |
| challenge 생성 | | navigator. | 내장 | |
| 서명 검증 | | credentials.* | API | 개인키 보관 |
+------------------+ +---------------------+ +------------------+
핵심 아이디어는 단순합니다. 비대칭 키 쌍 기반의 challenge-response 인증입니다.
- 등록 시 인증기가 키 쌍을 생성하고, 공개키만 서버(Relying Party, RP)에 전달합니다.
- 개인키는 인증기(Secure Enclave, TPM, 보안 키의 secure element)를 절대 떠나지 않습니다.
- 로그인 시 서버가 무작위 challenge를 보내고, 인증기가 개인키로 서명하면 서버는 공개키로 검증합니다.
서버에는 공개키만 저장되므로, 서버 DB가 통째로 유출되어도 공격자가 얻는 것은 "검증용 공개키"뿐입니다. 비밀번호 해시 유출과는 위협 모델 자체가 다릅니다.
Registration Ceremony — 등록 의식 상세
WebAuthn 스펙은 등록 과정을 ceremony라고 부릅니다. 절차가 엄격하게 정의되어 있기 때문입니다.
사용자 브라우저 RP 서버 인증기
| | | |
| 등록 시작 | | |
|--------------->| POST /webauthn/begin | |
| |----------------------->| |
| | PublicKeyCredential | |
| | CreationOptions | |
| | (challenge, rp.id, | |
| | user.id, pubKey | |
| | CredParams ...) | |
| |<-----------------------| |
| | navigator.credentials.create() |
| |-------------------------------------------->|
| 생체인증/PIN | | |
|<--------------------------------------------------------------|
| 사용자 확인 | | |
|--------------------------------------------------------------->|
| | attestationObject + clientDataJSON |
| |<--------------------------------------------|
| | POST /webauthn/finish | |
| |----------------------->| |
| | 검증 후 공개키 저장 | |
서버가 내려주는 옵션 객체의 실제 모양은 다음과 같습니다.
{
"challenge": "Y2hhbGxlbmdlLXJhbmRvbS0zMmJ5dGVz",
"rp": {
"id": "example.com",
"name": "Example Corp"
},
"user": {
"id": "dXNlci1pZC1vcGFxdWU",
"name": "jdoe@example.com",
"displayName": "Jane Doe"
},
"pubKeyCredParams": [
{ "type": "public-key", "alg": -7 },
{ "type": "public-key", "alg": -257 }
],
"authenticatorSelection": {
"residentKey": "required",
"userVerification": "required",
"authenticatorAttachment": "platform"
},
"attestation": "none",
"timeout": 60000,
"excludeCredentials": []
}
각 필드의 의미를 짚어보겠습니다.
- challenge: 서버가 생성하는 최소 16바이트 이상의 암호학적 난수입니다. 리플레이 공격을 막는 핵심이며, 반드시 서버 세션에 저장해 두었다가 응답 검증 시 비교해야 합니다.
- rp.id: Relying Party 식별자. 등록되는 크리덴셜이 어느 도메인에 묶이는지를 결정합니다. 피싱 저항성의 근간이므로 뒤에서 자세히 다룹니다.
- user.id: 사용자를 식별하는 불투명(opaque) 바이트 시퀀스입니다. 이메일 같은 PII를 넣지 말고 무작위 UUID를 권장합니다. 한 번 정하면 바꾸기 어렵습니다.
- pubKeyCredParams: 허용 알고리즘 목록. COSE 알고리즘 식별자로, -7은 ES256(ECDSA P-256), -257은 RS256입니다. ES256을 최우선으로 두는 것이 표준 관행입니다.
- residentKey: discoverable credential(구 resident key) 요구 여부. passkey 경험(아이디 입력 없는 로그인)을 원하면 required로 설정해야 합니다.
- userVerification: 생체인증/PIN 등 "사용자 본인 확인"을 요구할지 여부. passwordless 1팩터 로그인이라면 required가 필수입니다.
- excludeCredentials: 이미 등록된 크리덴셜 ID 목록. 같은 인증기의 중복 등록을 방지합니다.
Attestation — 인증기의 출신 증명
attestation은 "이 공개키가 정말로 특정 제조사/모델의 인증기에서 생성되었다"는 것을 암호학적으로 증명하는 메커니즘입니다. 등록 응답의 attestationObject 안에 attestation statement가 담겨 옵니다.
attestation 요청 수준은 네 가지입니다.
| 값 | 의미 | 권장 시나리오 |
|---|---|---|
| none | attestation 불요 (기본값) | 컨슈머 서비스, 일반 사내 앱 |
| indirect | 익명화된 attestation 허용 | 거의 사용 안 함 |
| direct | 제조사 attestation 원문 요구 | 규제 산업, 인증기 모델 통제 필요 시 |
| enterprise | 개별 기기 식별 가능 (사전 합의된 환경) | 관리 기기 한정 배포 |
중요한 현실 하나: synced passkey(iCloud Keychain, Google Password Manager)는 대부분 attestation을 제공하지 않습니다. direct attestation을 강제하면 사실상 플랫폼 passkey를 차단하는 효과가 나므로, 정책 설계 시 반드시 고려해야 합니다.
attestation 검증 시 서버는 attestation statement의 인증서 체인을 FIDO Metadata Service(MDS)의 메타데이터와 대조하여, 인증기의 AAGUID(Authenticator Attestation GUID — 인증기 모델 식별자)와 보안 인증 수준(FIDO L1/L2 등)을 확인할 수 있습니다.
Authentication Ceremony — 로그인 의식 상세
로그인 절차는 등록과 대칭적입니다.
사용자 브라우저 RP 서버 인증기
| | | |
| 로그인 시작 | POST /webauthn/login/begin |
|--------------->|----------------------->| |
| | PublicKeyCredential | |
| | RequestOptions | |
| | (challenge, rpId, | |
| | allowCredentials) | |
| |<-----------------------| |
| | navigator.credentials.get() |
| |-------------------------------------------->|
| 생체인증/PIN | | |
|<------------------------------------------------------------|
| | authenticatorData + signature |
| | + clientDataJSON |
| |<--------------------------------------------|
| | POST /webauthn/login/finish |
| |----------------------->| |
| | 서명 검증 → 세션 발급| |
서버 측 검증에서 반드시 확인해야 하는 항목들입니다.
- challenge 일치: clientDataJSON 안의 challenge가 서버가 발급한 값과 같은지.
- origin 일치: clientDataJSON 안의 origin이 기대하는 출처(예: 운영 도메인의 HTTPS 출처)와 같은지.
- rpIdHash 일치: authenticatorData의 첫 32바이트가 rp.id의 SHA-256 해시와 같은지.
- UP/UV 플래그: User Present, User Verified 플래그가 정책에 부합하는지.
- 서명 검증: 등록 시 저장한 공개키로 서명이 유효한지.
- signCount: 서명 카운터가 이전 값보다 증가했는지(복제 인증기 탐지). 단, synced passkey는 카운터가 항상 0인 경우가 많아 "증가하지 않으면 차단"이 아니라 "이상 징후 로깅" 수준으로 다루는 것이 현실적입니다.
Synced Passkey vs Device-bound — 무엇이 다른가
"passkey"라는 용어는 마케팅적으로는 discoverable WebAuthn credential 전반을 가리키지만, 실무에서는 동기화 여부에 따라 보안 특성이 크게 갈립니다.
| 구분 | Synced Passkey | Device-bound Credential |
|---|---|---|
| 저장 위치 | 클라우드 키체인 (iCloud, Google PM, 1Password 등) | 단일 기기의 보안 하드웨어 |
| 기기 분실 시 | 다른 기기에서 복구 가능 | 크리덴셜 영구 소실 |
| attestation | 일반적으로 없음 | 제조사 attestation 가능 |
| 복제 가능성 | 클라우드 계정 보안에 의존 | 하드웨어적으로 불가 |
| 대표 예 | iPhone/Android passkey | YubiKey, 기업 관리 기기의 TPM 키 |
| 적합 시나리오 | 일반 직원, 컨슈머 | 특권 계정, 규제 워크로드 |
또 하나의 축은 인증기의 형태입니다.
| 구분 | Platform Authenticator | Roaming Authenticator |
|---|---|---|
| 형태 | 기기 내장 (Touch ID, Windows Hello, Android) | 외장 (USB/NFC/BLE 보안 키) |
| CTAP 통신 | OS 내부 API | CTAP2 over USB/NFC/BLE |
| 크로스 디바이스 | hybrid transport(QR + BLE 근접 확인)로 가능 | 키를 물리적으로 꽂으면 됨 |
| 비용 | 0원 (기기에 포함) | 키 구매 비용 |
엔터프라이즈 정책의 전형적인 조합은 다음과 같습니다.
- 일반 직원: synced passkey 허용 (UX와 복구 용이성 우선)
- 관리자/특권 계정: device-bound 보안 키 또는 관리 기기의 platform authenticator만 허용 (attestation + AAGUID 정책으로 강제)
피싱 저항성의 원리 — Origin Binding
passkey가 OTP나 푸시 MFA와 결정적으로 다른 지점이 바로 origin binding입니다.
OTP 피싱 시나리오를 떠올려 보십시오. 공격자가 진짜 사이트와 똑같이 생긴 가짜 사이트를 만들고, 사용자가 입력한 비밀번호와 OTP를 실시간으로 진짜 사이트에 중계(relay)합니다. 사용자는 6자리 숫자가 "어느 사이트를 위한 것인지" 구분할 수단이 없으므로 속을 수밖에 없습니다.
WebAuthn에서는 이 중계가 프로토콜 수준에서 차단됩니다.
- 크리덴셜은 등록 시 rp.id(도메인)에 바인딩됩니다. evil-example.com에서 example.com의 크리덴셜을 요청하면, 브라우저가 rp.id 검증 단계에서 거부합니다. 가짜 사이트에서는 애초에 서명 자체가 생성되지 않습니다.
- 서명 대상에 clientDataJSON이 포함되고, 그 안에 브라우저가 직접 기록한 origin이 들어갑니다. 설령 어떤 경로로 서명을 얻어내도, 서버가 origin 필드를 검증하는 순간 위조된 출처는 탄로납니다.
- challenge가 서명에 포함되므로 리플레이도 불가능합니다.
즉 "사용자가 조심해서" 피싱을 막는 것이 아니라, 브라우저와 프로토콜이 수학적으로 막아줍니다. 이것이 피싱 저항 MFA의 정의이며, NIST SP 800-63B의 AAL3 논의에서 hardware-based phishing-resistant authenticator가 거론되는 이유입니다.
Keycloak 26 Passkeys 통합
Keycloak은 오래전부터 WebAuthn을 지원했지만, 26.x에 들어서며 passkey 경험이 1급 시민이 되었습니다. 특히 26.4부터 로그인 폼에 passkeys가 통합되었고, 26.6(2026년 기준 최신 26.6.2)에서는 conditional UI 기반의 매끄러운 흐름이 기본 제공됩니다.
두 가지 UI 모드
- Conditional UI (autofill): 아이디 입력란에 포커스가 가면 브라우저가 해당 사이트에 등록된 passkey를 자동완성 형태로 제안합니다. 사용자는 아이디/비밀번호를 입력할 필요 없이 목록에서 선택만 하면 됩니다. WebAuthn의 conditional mediation 기능을 사용합니다.
- Modal UI: "passkey로 로그인" 버튼을 누르면 모달 다이얼로그로 인증기 선택 화면이 뜨는 명시적 흐름입니다.
Realm 설정
Admin Console 기준 경로는 다음과 같습니다.
- Authentication → Policies → WebAuthn Passwordless Policy 에서 passwordless용 정책을 설정합니다. (일반 WebAuthn Policy는 2팩터용, Passwordless Policy가 passkey용입니다.)
- Realm Settings → Login 에서 passkeys 관련 옵션을 활성화합니다.
kcadm CLI로 정책을 설정하는 예시입니다.
# WebAuthn Passwordless 정책 설정
/opt/keycloak/bin/kcadm.sh update realms/myrealm \
-s 'webAuthnPolicyPasswordlessRpEntityName=Example Corp' \
-s 'webAuthnPolicyPasswordlessRpId=example.com' \
-s 'webAuthnPolicyPasswordlessRequireResidentKey=Yes' \
-s 'webAuthnPolicyPasswordlessUserVerificationRequirement=required' \
-s 'webAuthnPolicyPasswordlessAttestationConveyancePreference=none' \
-s 'webAuthnPolicyPasswordlessSignatureAlgorithms=["ES256","RS256"]' \
-s 'webAuthnPolicyPasswordlessAuthenticatorAttachment=not specified' \
-s 'webAuthnPolicyPasswordlessCreateTimeout=60'
핵심 포인트는 세 가지입니다.
- RequireResidentKey = Yes: discoverable credential을 강제해야 conditional UI에서 크리덴셜이 검색됩니다.
- UserVerificationRequirement = required: 1팩터 passwordless이므로 본인 확인이 필수입니다.
- RpId: 최상위 도메인(example.com)으로 설정하면 하위 도메인(sso.example.com, app.example.com) 전체에서 크리덴셜을 공유할 수 있습니다. 단, 한 번 정하면 변경 시 기존 크리덴셜이 전부 무효화되므로 신중하게 결정해야 합니다.
인증 플로우 구성
passkey-first 로그인 플로우를 만들려면 browser flow를 복제해 다음과 같이 구성합니다.
Browser Flow (복제본: browser-passkeys)
├── Cookie [Alternative]
├── Identity Provider Redirector [Alternative]
└── Passkeys Forms (subflow) [Alternative]
├── Username/WebAuthn Form [Required]
│ └─ conditional UI로 passkey 자동완성 제안
└── Password + OTP (subflow) [Conditional - 폴백]
├── Password Form [Required]
└── OTP Form [Conditional]
26.6에서는 기본 제공되는 passkeys 통합 로그인 폼 덕분에 별도 커스텀 없이도 위와 유사한 경험을 활성화할 수 있습니다. 자세한 내용은 Keycloak 릴리스 노트를 참고하십시오.
사용자 등록 흐름
기존 사용자가 passkey를 등록하게 하려면 Required Action을 사용합니다.
# 특정 사용자에게 passkey 등록 required action 부여
/opt/keycloak/bin/kcadm.sh update users/USER-ID -r myrealm \
-s 'requiredActions=["webauthn-register-passwordless"]'
또는 Account Console(요즘 기본 테마)에서 사용자가 스스로 Signing in → Passkeys 메뉴를 통해 등록할 수 있습니다.
프론트엔드 코드 예제 — 직접 RP를 구현하는 경우
Keycloak을 쓰면 아래 코드를 직접 작성할 일은 없지만, 자체 RP를 구현하거나 동작을 이해하려면 알아야 합니다.
등록 (Registration)
async function registerPasskey() {
// 1. 서버에서 옵션 받기
const resp = await fetch('/webauthn/register/begin', { method: 'POST' });
const options = await resp.json();
// 2. base64url → ArrayBuffer 변환
options.challenge = base64urlToBuffer(options.challenge);
options.user.id = base64urlToBuffer(options.user.id);
options.excludeCredentials = (options.excludeCredentials || []).map((c) => ({
...c,
id: base64urlToBuffer(c.id),
}));
// 3. 인증기 호출 — OS의 생체인증 UI가 뜸
const credential = await navigator.credentials.create({ publicKey: options });
// 4. 결과를 서버로 전송
await fetch('/webauthn/register/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
attestationObject: bufferToBase64url(credential.response.attestationObject),
},
}),
});
}
Conditional UI 로그인
async function conditionalLogin() {
// 브라우저가 conditional mediation을 지원하는지 확인
if (
!window.PublicKeyCredential ||
!(await PublicKeyCredential.isConditionalMediationAvailable())
) {
return; // 폴백: 일반 폼 로그인
}
const resp = await fetch('/webauthn/login/begin', { method: 'POST' });
const options = await resp.json();
options.challenge = base64urlToBuffer(options.challenge);
// mediation: 'conditional' — 모달 대신 자동완성에 passkey 제안
const assertion = await navigator.credentials.get({
publicKey: options,
mediation: 'conditional',
});
await fetch('/webauthn/login/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: assertion.id,
rawId: bufferToBase64url(assertion.rawId),
response: {
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
signature: bufferToBase64url(assertion.response.signature),
userHandle: assertion.response.userHandle
? bufferToBase64url(assertion.response.userHandle)
: null,
},
}),
});
}
conditional UI를 쓰려면 HTML 입력란에 autocomplete 힌트가 필요합니다.
<input type="text" name="username" autocomplete="username webauthn" />
점진적 롤아웃 전략 — MFA에서 Passwordless로
빅뱅 전환은 반드시 실패합니다. 권장하는 단계별 전략은 다음과 같습니다.
Phase 0 Phase 1 Phase 2 Phase 3
파일럿 2팩터로 도입 passkey-first passwordless
───────── ───────────── ────────────── ─────────────
IT/보안팀 비밀번호 + conditional UI로 비밀번호 제거
자원자 그룹 passkey(2FA) passkey 우선 제안 또는 비활성화
OTP 대체 시작 비밀번호는 폴백 폴백은 복구
플로우만
- Phase 0 (파일럿, 2~4주): IT/보안팀과 자원자 그룹으로 시작합니다. 브라우저/OS 매트릭스(구형 Windows, 가상 데스크톱, kiosk 환경)에서 막히는 지점을 찾는 것이 목적입니다.
- Phase 1 (2팩터 공존): passkey를 OTP를 대체하는 2번째 팩터로 도입합니다. 사용자에게 익숙해질 시간을 주면서 등록률(enrollment rate)을 올립니다. 이 단계의 핵심 지표는 활성 사용자 대비 passkey 등록률입니다.
- Phase 2 (passkey-first): 로그인 화면에서 passkey를 기본 경로로, 비밀번호를 폴백으로 둡니다. Keycloak의 conditional UI가 이 단계에서 빛을 발합니다.
- Phase 3 (passwordless): 등록률이 충분히 높아지면(경험상 90% 이상) 비밀번호 인증을 비활성화합니다. 이때 복구 플로우의 견고함이 전체 보안 수준을 결정합니다.
각 단계에서 모니터링할 지표: 등록률, passkey 로그인 성공률, 폴백 사용률, 헬프데스크 티켓 추이.
계정 복구 — Passwordless의 아킬레스건
비밀번호를 없애면 "비밀번호를 잊었어요"는 사라지지만 "기기를 잃어버렸어요"가 그 자리를 차지합니다. 복구 경로가 피싱 가능하면 전체 시스템의 보안은 복구 경로 수준으로 떨어집니다. 공격자는 언제나 가장 약한 고리를 노립니다.
권장 원칙은 다음과 같습니다.
- 복수 크리덴셜 등록을 기본으로: 온보딩 시 최소 2개(예: 노트북 platform authenticator + 스마트폰, 또는 passkey + 백업 보안 키) 등록을 정책으로 강제합니다. synced passkey는 그 자체로 복구 수단이 됩니다.
- 복구도 피싱 저항적으로: 이메일 매직 링크나 SMS로 복구를 허용하면 피싱 저항성 전체가 무너집니다. 사내 환경이라면 헬프데스크 대면/화상 신원 확인 + 임시 등록 토큰 발급이 정석입니다.
- 고가치 계정은 더 엄격하게: 관리자 계정 복구는 2인 승인(four-eyes) 절차를 권장합니다.
- 오프보딩과 연동: 퇴사/기기 반납 시 해당 크리덴셜을 즉시 무효화하는 프로세스를 IGA(Identity Governance)와 연결합니다.
Keycloak에서는 복구 시나리오를 위해 임시 required action 부여 + 기존 크리덴셜 삭제를 API로 자동화할 수 있습니다.
# 분실 기기의 크리덴셜 삭제
/opt/keycloak/bin/kcadm.sh delete \
users/USER-ID/credentials/CREDENTIAL-ID -r myrealm
# 재등록 required action 부여
/opt/keycloak/bin/kcadm.sh update users/USER-ID -r myrealm \
-s 'requiredActions=["webauthn-register-passwordless"]'
기업 정책 — Attestation 검증과 AAGUID 허용 목록
규제 산업이나 특권 계정에는 "어떤 인증기든 OK"가 통하지 않습니다. 이때 동원되는 것이 attestation 검증과 AAGUID 필터링입니다.
AAGUID는 인증기 모델별 128비트 식별자입니다. 예를 들어 특정 하드웨어 키 제품군, 특정 플랫폼 passkey 제공자를 AAGUID로 구분할 수 있습니다. FIDO MDS에서 AAGUID별 메타데이터(인증 수준, 알려진 취약점 여부)를 받아 검증 파이프라인에 통합합니다.
정책 설계 예시입니다.
| 사용자 그룹 | attestation | 허용 인증기 | 비고 |
|---|---|---|---|
| 일반 직원 | none | 모든 passkey | UX 우선 |
| 개발자 (운영 접근) | direct | 회사 지급 보안 키 + 관리 기기 platform | AAGUID 허용 목록 |
| 인프라 관리자 | direct | FIDO L2 인증 하드웨어 키만 | MDS 메타데이터 검증 |
Keycloak의 WebAuthn Passwordless Policy에서 Acceptable AAGUIDs 항목에 허용 목록을 등록하면, 목록 외 인증기의 등록이 거부됩니다. 주의할 점은 attestation conveyance를 none으로 두면 AAGUID가 zero로 올 수 있어 필터링이 무력화된다는 것입니다. AAGUID 정책을 쓰려면 attestation을 direct로 함께 올려야 합니다.
도입 시 흔한 실수 (안티패턴)
- rp.id를 좁게 잡고 시작: sso.example.com으로 등록을 시작했다가 나중에 example.com 전체로 확장하려면 모든 크리덴셜을 재등록해야 합니다. 처음부터 등록 가능한 가장 넓은 유효 도메인을 검토하십시오.
- userVerification을 discouraged로 두고 passwordless 운영: UP(존재 확인)만으로 1팩터 로그인을 허용하면 "기기를 주운 사람"이 로그인할 수 있습니다. passwordless에는 반드시 required.
- synced passkey에 direct attestation 강제: 플랫폼 passkey가 전부 등록 실패하면서 도입 자체가 좌초됩니다. 사용자 그룹별로 정책을 분리하십시오.
- signCount 불일치 시 무조건 차단: synced passkey는 카운터를 0으로 보내는 구현이 많습니다. 차단 대신 위험 신호로 로깅하고 다른 시그널과 조합하십시오.
- 복구 경로를 SMS/이메일로 열어둠: 피싱 저항 MFA를 도입해 놓고 복구는 피싱 가능한 채널로 열어두는 것은 자물쇠를 채우고 창문을 열어두는 격입니다.
- excludeCredentials 미설정: 같은 인증기를 중복 등록하게 되어 사용자 크리덴셜 목록이 지저분해지고 혼란이 생깁니다.
- challenge 재사용 또는 검증 생략: challenge를 stateless하게 처리하려고 검증을 건너뛰면 리플레이 공격에 무방비가 됩니다. 반드시 서버 측 상태(세션/캐시)와 대조해야 합니다.
- iframe/cross-origin 컨텍스트 미고려: 임베디드 로그인 위젯에서 WebAuthn 호출이 막히는 경우가 있습니다. Permissions Policy(publickey-credentials-get)를 점검하십시오.
- 등록률 지표 없이 Phase 3 진입: 등록률 60%에서 비밀번호를 꺼버리면 헬프데스크가 마비됩니다. 데이터 기반으로 전환 시점을 정하십시오.
마치며
passkey 도입은 기술적으로는 "WebAuthn API 연동"이지만, 실제로는 정책 설계와 변화 관리 프로젝트입니다. 프로토콜 자체(origin binding, challenge-response, attestation)는 이미 충분히 성숙했고, Keycloak 26 같은 오픈소스 IdP가 conditional UI까지 기본 제공하는 2026년 시점에서 기술적 장벽은 거의 사라졌습니다.
남은 과제는 조직의 것입니다. 어떤 사용자 그룹에 어떤 인증기를 허용할지, 복구 절차를 어떻게 피싱 저항적으로 설계할지, 등록률을 어떻게 끌어올릴지 — 이 글의 단계별 전략과 안티패턴 목록이 그 여정의 지도가 되기를 바랍니다.
다음 글에서는 인증 다음 단계인 인가(authorization) 모델의 진화 — RBAC, ABAC, ReBAC과 OpenFGA를 다룹니다.
참고 자료
- W3C Web Authentication Level 3 — WebAuthn 공식 스펙
- FIDO Alliance — Passkeys — passkey 개요 및 도입 자료
- FIDO Alliance Metadata Service — AAGUID 메타데이터
- Keycloak Documentation — 공식 문서
- Keycloak Release Notes — 26.x passkeys 기능 변경 사항
- Keycloak Server Administration Guide — WebAuthn — WebAuthn 정책 설정
- NIST SP 800-63B — Digital Identity Guidelines — AAL 및 인증기 요구사항
- OMB M-22-09 — Federal Zero Trust Strategy — 피싱 저항 MFA 의무화
- CTAP 2.1 — Client to Authenticator Protocol — CTAP 스펙
- passkeys.dev — 개발자 구현 가이드