Skip to content
Published on

WebAuthn & Passkeys Deep Dive — FIDO2, Attestation, Passkey Sync, 피싱 내성 인증 완전 정복 (2025)

Authors

TL;DR

  • WebAuthn(W3C, 2019)은 브라우저 표준 API. FIDO2(FIDO Alliance)의 한 축. 비밀번호 없이 공개 키 암호화로 인증.
  • 4 actor: Relying Party(웹사이트), User Agent(브라우저), Authenticator(TouchID/YubiKey 등), User.
  • 두 ceremony: Registration(공개 키 등록) + Authentication(challenge 서명).
  • Origin 바인딩: 크리덴셜은 example.com에서 만들면 example.com에서만 작동. 브라우저 + 인증기가 암호학적으로 강제 → 피싱 내성.
  • Attestation: 인증기가 "나는 진짜 YubiKey다"를 증명. 프라이버시 우려 → 대부분 웹사이트는 none으로 사용.
  • Passkeys: WebAuthn의 새 UX. Discoverable credential + 기기 간 동기화(iCloud Keychain, Google Password Manager)로 "비밀번호처럼 쓰되 훨씬 안전"을 실현.
  • Cryptography: ES256 (ECDSA P-256) 기본. RS256, EdDSA 선택 가능. CBOR로 직렬화, COSE 키 포맷.
  • Conditional UI: "자동완성처럼" passkey 제안. 2022년 이후 지원.
  • 서버 구현: @simplewebauthn/server, py_webauthn, webauthn4j. PublicKeyCredentialRequest/Response 처리.
  • 2025 현황: Apple/Google/Microsoft/1Password 본격 지원. 은행/커머스/소셜 미디어 대규모 채택 시작.

1. 암호 없는 인증이 필요한 이유

1.1 비밀번호의 근본 문제

비밀번호는 60년 동안 인증의 기본이었다. 1961년 MIT CTSS에서 시작. 하지만 근본적 결함:

  1. 재사용: 사용자가 여러 사이트에 같은 비밀번호. 한 곳 털리면 전체 털림.
  2. 피싱: 가짜 사이트가 비밀번호를 수집. 사용자가 속아서 입력.
  3. 브루트 포스: 약한 비밀번호는 몇 초.
  4. 서버 저장: 해시라도 유출되면 레인보우 테이블.
  5. 사용성: 수십 개 사이트마다 다른 비밀번호 기억 불가능.

1.2 MFA도 부족

TOTP, SMS 코드 같은 2차 인증은 부분 해결:

  • SMS: SIM swap 공격 취약.
  • TOTP: 피싱 사이트가 코드까지 요청 가능 ("실시간 phishing").
  • 사용자 부담: 매번 코드 입력.

2019년 Reddit 사례: 직원의 SMS 2FA를 공격자가 뚫고 내부 데이터 유출.

1.3 FIDO의 약속

FIDO Alliance (2012 창립, Google/Microsoft/PayPal 등)의 목표: "사용자 경험을 해치지 않으면서 피싱을 원천 차단".

핵심 아이디어:

  1. 공개 키 암호화: 비밀번호 대신 공개/개인 키 쌍.
  2. 개인 키는 절대 브라우저/서버에 노출되지 않음: 인증기(YubiKey, TouchID 등) 안에만.
  3. Origin 바인딩: 크리덴셜이 특정 도메인에만 작동.

결과: 피싱 사이트는 크리덴셜을 훔쳐도 쓸 수 없다 — 도메인이 다르면 서명이 맞지 않음.

1.4 역사

  • 2014 U2F: 두 번째 요소용. YubiKey 같은 물리 키.
  • 2018 FIDO2 / WebAuthn: 일차 요소로도. 브라우저 API.
  • 2019 WebAuthn L1: W3C 권고.
  • 2021 WebAuthn L2: 개선.
  • 2022 Passkeys: Apple WWDC 발표. 동기화된 크리덴셜.
  • 2023-2024: Google, Microsoft도 본격 지원.
  • 2025: 은행, 이메일, 쇼핑몰 대규모 도입.

2. 4 Actor 모델

WebAuthn을 이해하려면 누가 무엇을 하는지부터.

2.1 Relying Party (RP)

사용자가 로그인하려는 웹사이트. 예: github.com, google.com.

역할:

  • 등록 요청 시 challenge 생성.
  • 공개 키 저장.
  • 로그인 시 서명 검증.

RP ID: 도메인 (예: github.com). 크리덴셜이 바인딩되는 값.

2.2 User Agent

브라우저 (Chrome, Safari, Firefox).

역할:

  • RP와 Authenticator 사이의 중재자.
  • JavaScript API(navigator.credentials) 제공.
  • Origin 검증: RP ID가 현재 페이지와 일치하는지.
  • 권한 프롬프트 (사용자 동의).

핵심: 브라우저는 RP를 신뢰하지 않는다. RP가 임의 Authenticator와 직접 통신 불가. 모든 요청이 브라우저를 거친다.

2.3 Authenticator

실제 크리덴셜을 보관하고 서명하는 기기.

두 종류:

  • Platform Authenticator: 기기 내장. TouchID, Windows Hello, Android fingerprint.
  • Cross-platform Authenticator (Roaming): 외부 기기. YubiKey, Google Titan, SoloKey.

역할:

  • 키 쌍 생성 (ECDSA, RSA, EdDSA).
  • 개인 키 절대 외부로 내보내지 않음.
  • Challenge 서명.
  • 사용자 검증 (PIN, 지문, 얼굴).

2.4 User

그냥 사용자. 인증기를 터치하거나 지문을 찍는 사람.

User Presence (UP): "누군가 물리적으로 존재함" (버튼 누름). User Verification (UV): "이 사람이 맞음" (지문/PIN/얼굴).

WebAuthn 요청 시 UP는 항상 요구, UV는 옵션.

2.5 전체 그림

┌──────────┐       ┌──────────┐       ┌──────────────┐
User   │◄─────►│  Browser │◄─────►│  RP (서버)└─────┬────┘        (User    │       └──────────────┘
      │            │  Agent)      │            └─────┬────┘
      │                  │
        (사용자 동의)      (WebAuthn)
      ▼                  ▼
┌──────────────────────────┐
Authenticator  (TouchID / YubiKey /Windows Hello / ...)└──────────────────────────┘

RP ↔ Authenticator 통신은 항상 브라우저를 경유. RP가 "TouchID 직접 쓰겠다"고 할 수 없다.


3. Registration Ceremony

사용자가 처음 패스키를 등록하는 과정.

3.1 흐름

1. RP가 registration options 생성 → Browser에 JSON 전달
2. Browser가 navigator.credentials.create(options) 호출
3. Browser가 Authenticator에 요청 전달
4. Authenticator가 사용자 승인 요청 (UV)
5. Authenticator가 키 쌍 생성
6. Authenticator가 Attestation + 공개 키를 Browser에 반환
7. Browser가 RP에 전달
8. RP가 검증 후 공개 키 DB에 저장

3.2 RP 요청 생성

// 서버 (RP)
const options = {
  challenge: crypto.randomBytes(32),  // 랜덤
  rp: {
    name: "Example Corp",
    id: "example.com",
  },
  user: {
    id: userIdBytes,  // 사용자 고유 ID (bytes)
    name: "alice@example.com",
    displayName: "Alice",
  },
  pubKeyCredParams: [
    { alg: -7, type: "public-key" },   // ES256 (ECDSA P-256)
    { alg: -257, type: "public-key" }, // RS256
  ],
  authenticatorSelection: {
    authenticatorAttachment: "platform",  // 또는 "cross-platform"
    residentKey: "preferred",
    userVerification: "preferred",
  },
  timeout: 60000,
  attestation: "none",
};

res.json({ options });

주요 필드:

  • challenge: 재사용 공격 방어용 랜덤 바이트. 세션마다 새로.
  • rp.id: RP ID. 크리덴셜이 여기 바인딩. 도메인 형식.
  • user.id: 사용자 고유 ID. 재사용 불가(이메일 변경해도 이 값은 유지).
  • pubKeyCredParams: 원하는 알고리즘 목록, 선호도 순.
  • authenticatorSelection: 어떤 종류의 인증기.
  • attestation: "none" / "direct" / "indirect" / "enterprise".

3.3 브라우저 API 호출

// 브라우저 (페이지 JavaScript)
const credential = await navigator.credentials.create({
  publicKey: options,  // 서버에서 받은 옵션
});

// credential.response.attestationObject
// credential.response.clientDataJSON
// credential.rawId

이 호출 시 브라우저가:

  1. rp.id가 현재 origin과 호환되는지 확인.
  2. Authenticator 선택 UI 표시 (Touch ID 또는 외부 키).
  3. 사용자 상호작용 대기.

3.4 Authenticator 내부 동작

  1. 새 키 쌍 생성: (private_key, public_key). Curve P-256 (ES256).
  2. Credential ID 생성: 이 크리덴셜의 고유 ID.
  3. Private key 저장: 기기 안전 저장소 (Secure Enclave, TPM).
  4. Client Data 해시: RP가 보낸 challenge + origin + type을 SHA-256.
  5. Authenticator Data 생성: RP ID hash + flags + counter + credential data.
  6. Attestation: 인증기가 자기 proprietary 개인 키로 서명 (옵션).
  7. Browser에 반환.

3.5 Client Data

{
  "type": "webauthn.create",
  "challenge": "<base64url(random)>",
  "origin": "https://example.com",
  "crossOrigin": false
}

Browser가 만든다. Authenticator는 이걸 SHA-256 해시 → clientDataHash. 서명의 일부로 쓰인다.

중요: origin은 브라우저가 채워넣는다. JavaScript가 임의로 변경 불가. 피싱 방어의 핵심.

3.6 Authenticator Data

바이너리 구조:

┌────────────────────────────────────────────┐
RP ID Hash (32 bytes, SHA-256 of RP ID)├────────────────────────────────────────────┤
Flags (1 byte)- UP (User Present)- UV (User Verified)- AT (Attested credential data included)- ED (Extension data included)├────────────────────────────────────────────┤
Sign Counter (4 bytes)├────────────────────────────────────────────┤
AAGUID (16 bytes) - 인증기 모델 ID├────────────────────────────────────────────┤
Credential ID Length (2 bytes)├────────────────────────────────────────────┤
Credential ID (variable)├────────────────────────────────────────────┤
Credential Public Key (CBOR)├────────────────────────────────────────────┤
Extensions (optional, CBOR)└────────────────────────────────────────────┘

3.7 서버 검증

// 서버
const { clientDataJSON, attestationObject } = credential.response;

// 1. Client data parse
const clientData = JSON.parse(Buffer.from(clientDataJSON).toString());

// 2. 검증
if (clientData.type !== 'webauthn.create') throw new Error('wrong type');
if (clientData.challenge !== expectedChallenge) throw new Error('bad challenge');
if (clientData.origin !== 'https://example.com') throw new Error('bad origin');

// 3. Attestation object parse (CBOR)
const attObj = CBOR.decode(attestationObject);
// attObj.fmt: "none" | "packed" | "fido-u2f" | "tpm" | "android-key" | "apple"
// attObj.authData: bytes
// attObj.attStmt: attestation statement (알고리즘별)

// 4. Auth data parse
const authData = parseAuthData(attObj.authData);
// rpIdHash, flags, signCount, aaguid, credId, publicKey

// 5. RP ID 해시 일치 확인
if (!authData.rpIdHash.equals(sha256('example.com'))) throw new Error('bad rpId');

// 6. Flags 확인
if (!authData.flags.UP) throw new Error('user not present');
if (requireUV && !authData.flags.UV) throw new Error('user not verified');

// 7. Attestation 검증 (fmt에 따라)
if (attObj.fmt === 'packed') {
    verifyPackedAttestation(attObj, clientDataJSON);
}

// 8. DB 저장
db.users.update(userId, {
    credentials: [{
        credId: authData.credId,
        publicKey: authData.publicKey,
        signCount: authData.signCount,
        aaguid: authData.aaguid,
    }]
});

복잡하지만 각 단계가 의미 있다. 대부분은 라이브러리(@simplewebauthn/server)가 처리.


4. Authentication Ceremony

이미 등록된 사용자가 로그인하는 과정.

4.1 흐름

1. RP가 assertion options 생성 → 브라우저에
2. Browser가 navigator.credentials.get(options) 호출
3. Browser가 Authenticator에 요청
4. Authenticator가 사용자 인증
5. Authenticator가 challenge를 private key로 서명
6. Browser가 서명 + 공개 키 정보를 RP에 전달
7. RP가 저장된 공개 키로 서명 검증

4.2 RP 요청

const options = {
  challenge: crypto.randomBytes(32),
  rpId: "example.com",
  timeout: 60000,
  allowCredentials: [
    {
      id: existingCredId,  // 사용자의 credId 바이트
      type: "public-key",
      transports: ["internal", "usb", "nfc", "ble"],
    }
  ],
  userVerification: "preferred",
};

allowCredentials: 서버가 아는 이 사용자의 credential ID들. 인증기가 이 중 가진 것으로 서명.

allowCredentials 비움 + discoverable credential: 사용자가 어떤 credential 쓸지 선택. "username 없는 로그인".

4.3 브라우저 호출

const assertion = await navigator.credentials.get({
  publicKey: options,
});

// assertion.response.authenticatorData
// assertion.response.clientDataJSON
// assertion.response.signature
// assertion.response.userHandle (optional)

4.4 서명 구성

Authenticator가 서명하는 것:

signature = sign(privateKey, authenticatorData || SHA256(clientDataJSON))

||는 연결. Authenticator data 다음에 client data hash를 붙여서 서명.

4.5 서버 검증

const { authenticatorData, clientDataJSON, signature } = assertion.response;

// 1. Client data 검증 (type, challenge, origin)
const clientData = JSON.parse(Buffer.from(clientDataJSON).toString());
if (clientData.type !== 'webauthn.get') throw new Error('wrong type');
// ... challenge/origin 검증

// 2. Authenticator data parse
const authData = parseAuthData(authenticatorData);
if (!authData.rpIdHash.equals(sha256('example.com'))) throw new Error('bad rpId');
if (!authData.flags.UP) throw new Error('user not present');

// 3. 서명 검증
const credId = assertion.rawId;
const credential = db.getCredential(credId);

const signedData = Buffer.concat([
    authenticatorData,
    sha256(clientDataJSON),
]);

const valid = verifySignature(
    credential.publicKey,
    signedData,
    signature,
    credential.alg  // ES256, etc
);

if (!valid) throw new Error('bad signature');

// 4. Sign counter 검증 (옵션, clone 감지)
if (authData.signCount !== 0 && authData.signCount <= credential.signCount) {
    console.warn('Possible credential clone!');
}

// 5. Update counter
db.updateCredential(credId, { signCount: authData.signCount });

// 6. 로그인 완료
session.userId = credential.userId;

이 검증이 통과하면 사용자는 **"이 credential id의 개인 키를 현재 소유한다"**는 것을 증명한 셈. 로그인 인정.

4.6 왜 피싱에 면역인가

공격자가 evil.com에 가짜 로그인 페이지를 만들고 사용자를 유인한다고 하자.

  1. 사용자가 evil.com에서 "로그인" 클릭.
  2. evil.com의 JavaScript가 navigator.credentials.get({ rpId: "example.com" })을 호출하려 시도.
  3. 브라우저가 거부: 현재 origin은 evil.com, 요청된 rpId는 example.com. 불일치.

공격자가 rpId를 evil.com으로 바꿔도 사용자의 Authenticator가 evil.com에 대한 키를 가지고 있지 않으므로 실패.

공격자가 사용자에게 "새 credential을 등록해주세요"라고 해도 사용자는 evil.com용 credential을 만들 뿐 example.com에는 영향 없음.

비밀번호와 결정적 차이: 비밀번호는 복제 가능해서 피싱된 비밀번호가 진짜 사이트에도 통한다. Passkey는 도메인에 바인딩되어 복제해도 다른 도메인에서 못 쓴다.


5. CBOR과 COSE

WebAuthn은 JSON을 쓸 수도 있지만 저수준에서는 CBOR을 사용한다.

5.1 CBOR이란

Concise Binary Object Representation (RFC 8949). JSON과 유사하지만 바이너리:

JSON: {"a": 1, "b": "hello"}
     = 22 bytes

CBOR: a26161016162656865 6c6c6f
     = 9 bytes (59% 감소)
  • Self-describing.
  • 빠른 parsing.
  • 바이너리 데이터 직접 (base64 불필요).

5.2 왜 CBOR인가

  • 크기: NFC/BLE 같은 제한된 전송 매체에 유리.
  • 속도: 임베디드 인증기가 파싱하기 쉬움.
  • 타입: 바이너리 데이터 지원 (JSON은 hex/base64로 인코딩 필요).

WebAuthn attestation object는 CBOR:

{
  "fmt": "packed",
  "authData": h'...',
  "attStmt": { ... }
}

5.3 COSE Key

공개 키를 CBOR으로 표현. RFC 8152.

ES256 public key in COSE format:
{
  1: 2,         // kty = EC2
  3: -7,        // alg = ES256
  -1: 1,        // crv = P-256
  -2: h'<x coordinate>',
  -3: h'<y coordinate>',
}

숫자 키를 사용해 공간 절약. 매핑은 IANA 레지스트리.

Authenticator Data 안의 credential public key는 COSE 포맷.

5.4 서명 알고리즘

  • ES256 (-7): ECDSA with SHA-256 on P-256. 가장 일반적.
  • ES384 (-35): P-384.
  • EdDSA (-8): Ed25519. 빠르고 안전.
  • RS256 (-257): RSA SHA-256. 레거시.
  • PS256 (-37): RSASSA-PSS. 더 나은 RSA.

대부분 인증기가 ES256을 기본 지원. 서버는 여러 알고리즘을 받을 수 있도록.


6. Attestation — 인증기 신원 증명

6.1 개념

등록 시 인증기가 "나는 YubiKey 5 NFC입니다"를 증명할 수 있다. RP는 이를 신뢰할 수 있는지 결정.

6.2 Attestation 포맷

여러 가지:

  • none: attestation 없음. 프라이버시 우선.
  • packed: FIDO2 표준. AAGUID + 인증기 개인 키 서명.
  • fido-u2f: 레거시, U2F 키.
  • tpm: Windows TPM 기반.
  • android-key: Android 하드웨어.
  • android-safetynet: Android SafetyNet (폐기 중).
  • apple: Apple 기기.
  • apple-appattest: iOS App Attest.

6.3 Packed Attestation

attStmt: {
  "alg": -7,
  "sig": <인증기 attestation key로 서명>,
  "x5c": [<attestation certificate chain>]
}

서명 대상:

authenticatorData || clientDataHash

검증:

  1. x5c에서 인증서 체인 확인.
  2. 체인의 루트가 신뢰할 수 있는지? FIDO Metadata Service와 대조.
  3. 서명 검증.

6.4 FIDO Metadata Service

FIDO Alliance가 운영하는 인증기 메타데이터 저장소.

각 모델별로:

  • AAGUID.
  • 인증서.
  • 지원 알고리즘.
  • 보안 평가 (FIDO L1/L2).
  • Firmware 정보.

RP가 "이 AAGUID는 FIDO 인증됐는가"를 확인 가능.

6.5 Attestation의 딜레마

보안 관점: Attestation이 강하면 "진짜 인증기"만 허용 가능 → 악성 soft key 차단.

프라이버시 관점: 인증서가 인증기 모델을 식별 → 사용자 추적 가능성.

대부분 일반 웹사이트는 none 사용. 은행/정부 같은 고보안은 direct.

FIDO는 ECDAA (Elliptic Curve Direct Anonymous Attestation)를 개발했지만 복잡도 때문에 거의 쓰이지 않음. 대신 Enterprise Attestation (관리자 지정 상황) 보강.


7. Resident Key와 Discoverable Credential

7.1 두 종류의 크리덴셜

Server-side credential (옛 용어 "non-resident"):

  • 인증기가 자기 안에 저장 안 함.
  • Credential ID가 암호화된 개인 키를 담고 있음.
  • 서버가 credential ID를 주면 인증기가 복호화해서 서명.

Discoverable credential (옛 용어 "resident key"):

  • 인증기 안에 저장됨.
  • 저장 가능 개수 제한 (보통 수십 개).
  • 서버가 credential ID 없이 요청해도 인증기가 선택 제공.

7.2 왜 Discoverable가 중요한가

"Username 없는 로그인"을 가능하게 한다.

전통 로그인:

1. 사용자가 username 입력.
2. 서버가 allowCredentials를 user의 저장된 credential로 세팅.
3. 인증기가 그 credential로 서명.

Discoverable:

1. 사용자가 아무것도 입력 안 함.
2. 서버가 allowCredentials = [] 로 요청.
3. 브라우저가 인증기들에 "discoverable credential 있어?" 물음.
4. 인증기가 "네, 이거 있어요" + 사용자 선택 UI.
5. 사용자 선택 + 지문 → 서명.
6. 서버가 `userHandle`로 사용자 식별.

"Touch ID로 로그인" 경험의 기반.

7.3 저장 용량 제한

Hardware key는 일반적으로:

  • YubiKey 5: 25 discoverable credential.
  • Solo Key: 약 50.
  • Platform (TouchID 등): 사실상 무제한 (Secure Enclave에 저장).

Roaming key의 한계가 Passkey에서 중요한 이슈.


8. Passkeys — WebAuthn의 UX 혁명

8.1 WebAuthn의 어려움

2019년 WebAuthn이 표준화됐지만 대중 보급에 걸림돌:

  1. 단일 기기 종속: YubiKey를 잃으면 계정 상실.
  2. 기기마다 등록 필요: 휴대폰, 노트북, 사무실 PC 각각.
  3. 복구 복잡: 백업 인증기, 복구 코드 관리 어려움.
  4. 사용자 교육: "이게 뭔데" 반응.

8.2 Passkeys의 해결

Apple의 2022 WWDC 발표: "Passkeys" = 동기화된 Discoverable Credential.

  • iCloud Keychain이 개인 키를 기기 간 암호화 동기화.
  • 사용자는 iPhone, iPad, Mac 어디서나 같은 passkey 사용.
  • 새 기기 추가 시 자동 전파.
  • 잃어버려도 iCloud 계정으로 복구.

Google이 Google Password Manager로 따라함. Microsoft도 Windows Hello + Microsoft Account.

8.3 기술적으로 변한 것

놀랍게도 거의 없다. WebAuthn 프로토콜 자체는 그대로. 변한 것:

  1. 플랫폼이 개인 키를 OS-level 암호화 동기화 서비스에 저장.
  2. Attestation 필드 변경:
    • backupEligible 플래그 (동기화 가능).
    • backupState 플래그 (실제 동기화됨).
  3. AAGUID가 **"패스키 매니저"**를 가리킴 (예: Apple, Google).

8.4 Sync Fabric

어떻게 기기 간 안전하게 동기화?

Apple iCloud Keychain:

  • 사용자 기기 하나가 마스터 키 생성.
  • 새 기기 추가 시 기존 기기의 approval 필요.
  • End-to-end 암호화: Apple 서버도 passkey를 볼 수 없음.
  • 사용자 기기들이 동료 간 동기화 (Secure Enclave 사용).

Google Password Manager:

  • Google 계정에 연결.
  • 2-step verification 또는 주 기기 approval 필요.
  • Google 서버가 접근 못 함 (E2E 암호화).

Microsoft:

  • Microsoft Account.
  • Windows Hello 사용.

2024년부터 각 플랫폼 간 교차 호환 작업 중 (FIDO Alliance의 CXF — Credential Exchange Format).

8.5 Device-bound vs Synced

Device-bound Passkey:

  • 동기화 안 됨.
  • 잃으면 상실.
  • 높은 보증.
  • 은행, 정부 용.

Synced Passkey (일반 "Passkey"):

  • 자동 동기화.
  • 복구 가능.
  • 소비자 용.

RP는 둘 중 어느 것을 요구할지 선택 가능. authenticatorAttachment: "cross-platform" + attestation 엄격 = device-bound.

8.6 Cross-Device Authentication (caBLE / Hybrid)

폰의 passkey로 노트북에서 로그인:

1. 노트북이 브라우저에서 로그인 시작.
2. 브라우저가 QR 코드 표시.
3. 폰으로 QR 스캔.
4. 폰과 노트북이 BLE로 proximity 확인.
5. 폰이 서명 후 결과를 노트북에 전달 (암호화).
6. 노트북이 서버에 전달.

이것이 "하이브리드" 또는 "caBLE"(cloud-assisted BLE). FIDO2.2에 표준화.

8.7 Conditional UI

"자동완성처럼" passkey 제안:

<input type="text" autocomplete="username webauthn">

webauthn 힌트로 브라우저가 이 필드에 passkey 자동완성 드롭다운 표시. 사용자가 선택하면 바로 인증.

JavaScript:

navigator.credentials.get({
    publicKey: options,
    mediation: "conditional"
});

사용자가 입력하다가 passkey 옵션을 발견 → 클릭 → 지문 → 로그인. 매끄러운 UX.

Chrome 108+, Safari 16+ 지원.


9. 서버 구현 패턴

9.1 라이브러리

거의 모든 서버는 라이브러리 사용:

  • Node.js: @simplewebauthn/server.
  • Python: py_webauthn.
  • Go: go-webauthn.
  • Java: webauthn4j.
  • Rust: webauthn-rs.
  • .NET: Fido2.

직접 구현 권장 안 함. CBOR 파싱, 암호 검증 실수 많음.

9.2 데이터베이스 스키마

CREATE TABLE users (
    id UUID PRIMARY KEY,
    email VARCHAR UNIQUE NOT NULL,
    display_name VARCHAR,
    created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE credentials (
    id BYTEA PRIMARY KEY,            -- credential ID (bytes)
    user_id UUID REFERENCES users(id),
    public_key BYTEA NOT NULL,       -- COSE 형식
    algorithm INT NOT NULL,
    counter BIGINT DEFAULT 0,
    transports VARCHAR[],
    aaguid UUID,
    backup_eligible BOOLEAN,
    backup_state BOOLEAN,
    name VARCHAR,                    -- "Alice's iPhone"
    created_at TIMESTAMP DEFAULT NOW(),
    last_used_at TIMESTAMP
);

CREATE INDEX idx_credentials_user ON credentials(user_id);

한 사용자가 여러 credential (여러 기기, 여러 인증기).

9.3 Registration 엔드포인트

// POST /webauthn/register/begin
app.post('/webauthn/register/begin', async (req, res) => {
    const user = req.user;  // 로그인 또는 새 사용자
    const options = generateRegistrationOptions({
        rpName: "Example",
        rpID: "example.com",
        userID: user.id,
        userName: user.email,
        attestationType: "none",
        authenticatorSelection: {
            residentKey: "required",
            userVerification: "preferred",
        },
    });

    // challenge 세션에 저장
    req.session.currentChallenge = options.challenge;
    res.json(options);
});

// POST /webauthn/register/complete
app.post('/webauthn/register/complete', async (req, res) => {
    const { body } = req;
    const expectedChallenge = req.session.currentChallenge;

    const verification = await verifyRegistrationResponse({
        response: body,
        expectedChallenge,
        expectedOrigin: "https://example.com",
        expectedRPID: "example.com",
    });

    if (verification.verified) {
        const { credentialID, credentialPublicKey, counter } =
            verification.registrationInfo;
        await db.credentials.insert({
            id: credentialID,
            userId: req.user.id,
            publicKey: credentialPublicKey,
            counter,
        });
        res.json({ ok: true });
    } else {
        res.status(400).json({ ok: false });
    }
});

9.4 Authentication 엔드포인트

// POST /webauthn/login/begin
app.post('/webauthn/login/begin', async (req, res) => {
    const { email } = req.body;
    const options = generateAuthenticationOptions({
        rpID: "example.com",
        userVerification: "preferred",
        allowCredentials: email
            ? (await getCredsForEmail(email)).map(c => ({
                id: c.id,
                type: "public-key",
                transports: c.transports,
            }))
            : [],  // empty for discoverable
    });

    req.session.currentChallenge = options.challenge;
    res.json(options);
});

// POST /webauthn/login/complete
app.post('/webauthn/login/complete', async (req, res) => {
    const { body } = req;
    const credId = body.id;
    const credential = await db.credentials.findByCredId(credId);
    if (!credential) return res.status(404).json({});

    const verification = await verifyAuthenticationResponse({
        response: body,
        expectedChallenge: req.session.currentChallenge,
        expectedOrigin: "https://example.com",
        expectedRPID: "example.com",
        authenticator: {
            credentialID: credential.id,
            credentialPublicKey: credential.publicKey,
            counter: credential.counter,
        },
    });

    if (verification.verified) {
        await db.credentials.updateCounter(
            credential.id,
            verification.authenticationInfo.newCounter
        );
        req.session.userId = credential.userId;
        res.json({ ok: true });
    } else {
        res.status(400).json({});
    }
});

9.5 Sign Counter의 복잡성

원래 의도: clone 감지. 인증기가 signCount를 증가시키는데, 서버가 "이전 값보다 작거나 같다"면 clone 의심.

문제:

  • Passkey sync: 여러 기기가 같은 key 공유 → counter 관리 어려움.
  • 대부분 passkey는 counter 0 고정 (iCloud, Google).

실무: counter가 0이 아니면 검증, 0이면 무시. FIDO 표준도 이쪽으로 가고 있음.


10. 보안 고려사항

10.1 피싱 내성의 한계

Passkey는 도메인 기반 피싱을 막는다. 하지만:

  • IDN 동형 글자: еxample.com (키릴 e)은 다른 도메인. 사용자가 타자 실수로 등록했다면 해당 credential이 еxample.com에만 작동.
  • Subdomain 공격: RP ID가 example.com이면 evil.example.com의 JavaScript가 그 credential 사용 가능. Subdomain takeover 주의.
  • 크리덴셜 이전: 공격자가 credential 탈취 후 복제? 개인 키는 Authenticator 밖으로 나가지 않음. Passkey sync는 OS 수준에서 암호화.

10.2 MITM

WebAuthn은 TLS 위에 있다. TLS가 뚫리면 challenge 재생 공격 가능. 하지만:

  • origin 검증이 TLS 검증에 의존.
  • 기업 MITM proxy에서 WebAuthn이 작동 안 할 수도 (origin 불일치).

10.3 소셜 엔지니어링

"당신의 비밀번호를 알려주세요" → 비밀번호는 넘겨줄 수 있지만 passkey는 못 넘김 (외부에 export 불가). 이것이 SE에 강한 이유.

단, "이 링크를 열어주세요"로 유인된 사용자가 악성 사이트에 새 passkey 등록할 수 있다. 그 passkey는 악성 사이트만 사용 가능하지만 사용자는 혼란 가능.

10.4 복구 공격

사용자가 passkey를 잃으면? Password recovery처럼 email으로 복구가 일반적 → 복구 링크가 새 약점. 복구 메일 자체가 피싱 대상.

해결책: 복수 authenticator 등록 (폰 + 노트북 + YubiKey). 하나 잃어도 다른 걸로 복구 passkey 추가.

10.5 Passkey 공유?

원래 passkey는 개인 소유. 하지만 2024년 1Password/Dashlane이 팀 공유 passkey 지원. 가족 이메일, 공유 서비스 등.

보안 관점에서 논쟁 중. "Passkey 철학에 반한다" vs "현실적 필요".


11. 브라우저/플랫폼 지원

11.1 2025년 현황

브라우저:

  • Chrome/Edge: 모든 플랫폼에서 완전 지원.
  • Safari: macOS/iOS. iCloud Keychain 통합.
  • Firefox: 지원, 하지만 conditional UI 약함.

플랫폼 인증기:

  • TouchID / FaceID (macOS/iOS).
  • Windows Hello (Win10+).
  • Android Biometric (Pixel, Samsung 등).
  • ChromeOS.

Cross-platform:

  • YubiKey (USB, NFC).
  • Google Titan.
  • Solo, OnlyKey 같은 오픈소스.

11.2 브라우저 API

핵심 메소드:

// Feature detection
if (window.PublicKeyCredential) {
    // WebAuthn 지원
}

// Conditional UI 지원 확인
if (await PublicKeyCredential.isConditionalMediationAvailable?.()) {
    // 자동완성 드롭다운 가능
}

// Platform authenticator 지원 확인
if (await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()) {
    // TouchID/Windows Hello 있음
}

12. 디버깅

12.1 WebAuthn.io

https://webauthn.io — 공식 테스트 사이트. 등록/인증 flow 테스트.

12.2 Chrome DevTools

Chrome DevTools의 "WebAuthn" 탭:

  • Virtual authenticator 생성 (실제 하드웨어 없이 테스트).
  • 등록된 credential 보기/수정.
  • Sign counter 조작.

개발에 매우 유용.

12.3 일반적 에러

NotAllowedError: 사용자가 취소 or 타임아웃.

InvalidStateError: 이미 등록된 credential 재등록 시도.

SecurityError: RP ID 불일치, HTTPS 아님, iframe 문제.

NotSupportedError: 요청한 알고리즘 미지원.

12.4 iframe 문제

WebAuthn은 기본적으로 top-level origin에서만 작동. iframe 안에서 쓰려면:

<iframe allow="publickey-credentials-get"></iframe>

Permission Policy로 명시적 허용 필요. CSRF 방어.


13. 학습 리소스

공식:

:

  • "Solving Identity Management in Modern Applications" — Yvonne Wilson.
  • "Passwordless Authentication with Passkeys" — 새 책 (2024).

온라인:

  • OktaDev YouTube 채널.
  • Google Identity developer blog.
  • Apple WWDC 2022 "Meet passkeys" 세션.

구현:

  • @simplewebauthn/server Node.js 라이브러리 소스.
  • webauthn4j Java 구현.
  • Chromium 소스 (authenticator 코드).

14. 요약 — 한 장 정리

┌─────────────────────────────────────────────────────┐
WebAuthn / Passkeys Cheat Sheet├─────────────────────────────────────────────────────┤
4 Actors:Relying Party (서버)User Agent (브라우저)Authenticator (TouchID/YubiKey)User│                                                       │
2 Ceremonies:Registration: 키 쌍 생성 + 공개 키 등록              │
Authentication: challenge 서명 검증                 │
│                                                       │
│ 핵심 방어:Origin 바인딩 (RP ID)│   브라우저가 강제                                      │
│   피싱 사이트에서 credential 사용 불가                  │
│                                                       │
Client Data:│   type: "webauthn.create" or "webauthn.get"│   challenge: 서버 생성 랜덤                            │
│   origin: 브라우저가 채움 (신뢰 가능)│                                                       │
Authenticator Data:RP ID hash                                          │
Flags (UP, UV, AT, ED)Sign counter                                        │
AAGUIDCredential ID + Public Key (COSE)│                                                       │
│ 서명:sign(privKey, authData || sha256(clientData))ES256 (기본), EdDSA, RS256│                                                       │
Attestation:none (프라이버시)│   packed/fido-u2f/tpm/apple                          │
FIDO Metadata Service로 검증                        │
│                                                       │
Credential 종류:Server-side (non-resident)Discoverable (resident key)│                                                       │
Passkey = Discoverable credential + Sync:│   iCloud Keychain / Google PM / MS AccountEnd-to-end encrypted                               │
Cross-device via caBLE/Hybrid (QR + BLE)│                                                       │
Conditional UI:│   autocomplete="username webauthn"│   자동완성 드롭다운                                    │
│                                                       │
│ 라이브러리:│   @simplewebauthn/server (Node)py_webauthn (Python)webauthn4j (Java)│   webauthn-rs (Rust)│                                                       │
│ 검증 체크리스트:│   ✓ clientData.type│   ✓ clientData.challenge│   ✓ clientData.origin│   ✓ authData.rpIdHash│   ✓ authData.flags (UP, UV)│   ✓ 서명 (publicKey)│   ✓ signCount (옵션)└─────────────────────────────────────────────────────┘

15. 퀴즈

Q1. WebAuthn이 피싱에 내성인 이유는?

A. Origin 바인딩과 브라우저의 강제. Credential이 생성될 때 특정 RP ID(도메인)에 바인딩되고, 서명 시 Client Data에 origin이 포함된다. 브라우저가 이 값을 채우므로(JavaScript가 변경 불가) 악성 사이트가 자기 도메인을 숨길 수 없다. 사용자가 example.com의 credential을 가지고 있어도 evil.com에서는 작동 안 함 — evil.com용 새 credential이 필요한데 만들어봐야 example.com에는 영향 없다. 비밀번호와 달리 복제해도 다른 도메인에서 못 쓴다. 이것이 "피싱 내성"의 암호학적 근거.

Q2. Registration ceremony에서 "sign" 대상은 무엇인가?

A. authenticatorData || SHA-256(clientDataJSON). ||는 연결. Authenticator가 이 데이터를 private key로 서명해 공개 키와 함께 RP로 반환. authenticatorData는 RP ID hash, flags, counter, AAGUID, credential ID, 공개 키를 포함. clientData는 challenge, origin, type을 포함. 이 두 값이 서명 안에 묶이므로 challenge 재사용, origin 조작, credential 바꿔치기가 불가능. Authentication ceremony도 같은 구조로 서명 (단 attestation 없음).

Q3. Discoverable credential (resident key)이 일반 credential과 다른 점은?

A. 인증기가 credential을 스스로 보관하는지의 차이. 일반 credential(server-side)은 credential ID 안에 암호화된 개인 키가 담겨 있고, 인증기는 이를 런타임에 복호화해서 서명 — 서버가 credential ID를 제공해야 작동. Discoverable는 인증기 자체가 개인 키를 저장 → 서버가 credential ID 없이 "이 RP ID용 credential 있어?"만 물으면 인증기가 가진 걸 제시 → "username 없는 로그인" 가능. Passkey가 이걸 기반으로 한다. 단점: 인증기 저장 용량 제한 (하드웨어 키는 25-50개, 플랫폼 인증기는 사실상 무제한).

Q4. Passkey가 WebAuthn과 기술적으로 어떻게 다른가?

A. 거의 똑같다. Passkey는 WebAuthn의 UX 개선이지 새 프로토콜이 아니다. 바뀐 것은: (1) 개인 키를 OS-level 암호화 동기화 서비스에 저장 (iCloud Keychain, Google Password Manager, Microsoft Account). 기기 간 암호화 전파. (2) Authenticator data의 flag에 backupEligiblebackupState 추가 — "이 credential은 sync 가능인가" / "실제 동기화됐는가". (3) AAGUID가 **"패스키 매니저"**를 가리킴. 이 단순한 변경이 "기기 잃으면 계정 상실"이라는 WebAuthn의 가장 큰 걸림돌을 제거했다. 암호학적 보안은 그대로, 복구 경험은 "비밀번호처럼" 익숙.

Q5. Attestation에서 프라이버시와 보안의 트레이드오프는?

A. 인증기 식별 가능성 vs 사용자 추적. 강한 attestation(direct)은 "이 인증기는 진짜 YubiKey 5 NFC입니다"를 증명 → 악성 soft key 차단, 하드웨어 보증 수준 확인. 하지만 attestation 인증서는 인증기 모델을 드러내고 배치별로 고유할 수 있어 사용자 추적에 쓰일 수 있다. FIDO는 이를 완화하려 ECDAA(Direct Anonymous Attestation) 개발했지만 복잡해서 거의 안 쓰임. 대부분 일반 웹사이트는 none 사용 (프라이버시 우선). 은행/정부/기업 환경은 direct 사용하고 FIDO Metadata Service로 신뢰 판단. "필요할 때만 attestation을 요구"가 일반 원칙.

Q6. Cross-Device Authentication (caBLE/Hybrid)은 어떻게 작동하는가?

A. 폰의 passkey로 노트북에서 로그인하는 메커니즘. 흐름: (1) 노트북 브라우저가 QR 코드 표시 (인증 요청 + 임시 키 정보), (2) 사용자가 폰으로 QR 스캔, (3) 폰과 노트북이 BLE로 proximity 확인 (물리적으로 근처에 있음 증명, relay 공격 방어), (4) 폰이 Cloud-Assisted BLE(caBLE) 채널로 인증기 역할 수행 → 지문 + 서명, (5) 결과가 암호화되어 노트북에 전달, (6) 노트북이 서버에 전달. 이 메커니즘 덕분에 "폰 없으면 로그인 못 하는" 상황 최소화. FIDO 2.2 표준, 2023년부터 주요 브라우저 지원. "Hybrid" 또는 "caBLE"로 부른다.

Q7. Sign counter가 왜 Passkey 시대에 문제인가?

A. 원래 의도는 clone 감지. 인증기가 매 서명마다 counter를 증가시키므로 서버가 "새 counter가 이전보다 작거나 같으면 clone 의심"을 할 수 있었다. 문제: Passkey sync가 이 모델을 깬다. iCloud Keychain이 같은 private key를 여러 기기에 복제하면, 기기 A가 counter를 5로 올리고 기기 B가 아직 2라면 다음 서명이 2 → 서버가 clone으로 오판. 해결책: 대부분의 passkey 플랫폼이 counter를 0으로 고정. 서버도 "counter=0이면 검증 무시, 0이 아니면 이전보다 커야"로 관대. FIDO 표준도 counter의 중요성을 낮추는 방향으로 진화 중. "보안 기능이 다른 보안 기능(sync)과 충돌"한 사례.


이 글이 도움이 됐다면 다음 포스트도 확인해 보세요:

  • "OAuth 2.0 & OIDC Deep Dive" — 또 다른 인증 표준과의 차이.
  • "TLS/SSL Deep Dive" — 공개 키 암호화의 배경.
  • "Binary Serialization (Protobuf/CBOR/...)" — CBOR의 자세한 내용.
  • "DNS Deep Dive" — Origin 기반 보안의 기반.