Passkey 이해하기 2 - 코드로 배우는 실전 구현
2025. 12. 24. 15:57

이 글에서는 실제 동작하는 Passkey 데모 프로젝트를 통해 등록과 로그인을 어떻게 구현하는지 알아본다.

전체 코드는 GitHub 레포지토리에서 확인할 수 있다.

전체 플로우 한눈에 보기

Passkey 등록 플로우

1. 클라이언트: 사용자 이름 입력 후 "등록" 버튼 클릭
   ↓
2. 클라이언트 → 서버: POST /register/start { username }
   ↓
3. 서버: challenge 생성 및 PublicKeyCredentialCreationOptions 반환
   ↓
4. 클라이언트: navigator.credentials.create() 호출 → 생체 인증 진행
   ↓
5. 브라우저: 공개키/비밀키 쌍 생성, 비밀키는 디바이스에 저장
   ↓
6. 클라이언트 → 서버: POST /register/finish { credential }
   ↓
7. 서버: credential 검증 후 공개키 저장
   ↓
8. 완료!

Passkey 로그인 플로우

1. 클라이언트: "로그인" 버튼 클릭
   ↓
2. 클라이언트 → 서버: POST /login/start
   ↓
3. 서버: challenge 생성 및 PublicKeyCredentialRequestOptions 반환
   ↓
4. 클라이언트: navigator.credentials.get() 호출 → 생체 인증 진행
   ↓
5. 브라우저: 저장된 비밀키로 서명 생성
   ↓
6. 클라이언트 → 서버: POST /login/finish { credential }
   ↓
7. 서버: 공개키로 서명 검증 (이 부분은 복잡하여 코드에서 확인)
   ↓
8. 로그인 성공!

프로젝트 구조

passkey-demo/
├── server/
│   ├── server.js              # Express 서버
│   └── package.json
└── client/
    ├── src/
    │   ├── App.jsx            # React 컴포넌트
    │   └── utils/
    │       └── webauthn.js    # WebAuthn 유틸리티
    └── package.json

서버는 Express로 API를 제공하고, 클라이언트는 React + Vite로 구성되어 있다.

참고: 이 데모에서는 데이터 저장소로 메모리 내 변수(db)를 사용한다. 서버를 재시작하면 모든 데이터가 사라지는 휘발성 저장 방식이다. 실제 프로덕션 환경에서는 PostgreSQL, MongoDB 등의 영구 데이터베이스를 사용해야 한다.

Passkey 등록 구현

1단계: 등록 시작 API (서버)

클라이언트가 사용자 이름과 함께 등록을 요청하면, 서버는 challenge를 생성하고 등록에 필요한 옵션을 반환한다.

// server.js
app.post("/register/start", (req, res) => {
  const { username } = req.body;

  // 사용자 찾기 또는 생성
  const user = findOrCreateUser(username);

  // 랜덤 challenge 생성 (32바이트)
  const challenge = bufferToBase64url(crypto.randomBytes(32));

  // challenge 저장 (10분 유효)
  db.challenges.set(challenge, {
    userId: user.id,
    timestamp: Date.now(),
    type: "register",
  });

  // PublicKeyCredentialCreationOptions 생성
  const options = {
    challenge,
    rp: {
      name: "Passkey Demo",
      id: "localhost",
    },
    user: {
      id: bufferToBase64url(Buffer.from(user.id)),
      name: username,
      displayName: username,
    },
    pubKeyCredParams: [
      { type: "public-key", alg: -7 },   // ES256
      { type: "public-key", alg: -257 }, // RS256
    ],
    authenticatorSelection: {
      authenticatorAttachment: "platform",
      requireResidentKey: true,
      userVerification: "required",
    },
    timeout: 60000,
    attestation: "none",
  };

  res.json(options);
});

주요 옵션 설명:

  • challenge: 일회용 랜덤 값으로 Replay attack 방지
  • rp.id: 도메인 (실제 배포 시 실제 도메인 사용)
  • user.id: 사용자 고유 ID (UUID 등)
  • pubKeyCredParams: 지원할 암호화 알고리즘 (ES256 권장)
  • requireResidentKey: true: Discoverable Credential 활성화
  • userVerification: "required": 생체 인증 필수

2단계: WebAuthn API 호출 (클라이언트)

서버로부터 받은 옵션을 사용해 navigator.credentials.create()를 호출한다.

// client/src/utils/webauthn.js
export async function registerPasskey(username) {
  // 1. 서버로부터 challenge와 옵션 받기
  const startResponse = await fetch(`${API_URL}/register/start`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username })
  });

  const options = await startResponse.json();

  // 2. Base64URL 문자열을 ArrayBuffer로 변환
  const publicKeyOptions = {
    ...options,
    challenge: base64urlToBuffer(options.challenge),
    user: {
      ...options.user,
      id: base64urlToBuffer(options.user.id)
    }
  };

  // 3. WebAuthn API 호출 - 생체 인증 시작
  const credential = await navigator.credentials.create({
    publicKey: publicKeyOptions
  });

  // 4. Credential을 서버로 전송하기 위해 직렬화
  const credentialJSON = {
    id: credential.id,
    rawId: bufferToBase64url(credential.rawId),
    type: credential.type,
    response: {
      clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
      attestationObject: bufferToBase64url(credential.response.attestationObject),
      clientDataJSON_challenge: JSON.parse(
        new TextDecoder().decode(credential.response.clientDataJSON)
      ).challenge
    }
  };

  // 공개키 추출 (브라우저가 지원하는 경우)
  if (credential.response.getPublicKey) {
    const publicKeyBuffer = credential.response.getPublicKey();
    if (publicKeyBuffer) {
      credentialJSON.response.publicKey = bufferToBase64url(publicKeyBuffer);
    }
  }

  // 5. 서버로 credential 전송
  const finishResponse = await fetch(`${API_URL}/register/finish`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, credential: credentialJSON })
  });

  return await finishResponse.json();
}

Base64URL 변환이 필요한 이유:
WebAuthn API는 ArrayBuffer를 사용하지만, JSON으로 서버와 통신하려면 문자열로 변환(직렬화)해야 한다.

// ArrayBuffer → Base64URL 문자열
export function bufferToBase64url(buffer) {
  const bytes = new Uint8Array(buffer);
  let binary = '';
  for (let i = 0; i < bytes.byteLength; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

// Base64URL 문자열 → ArrayBuffer
export function base64urlToBuffer(base64url) {
  const base64 = base64url
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  const padded = base64.padEnd(
    base64.length + (4 - base64.length % 4) % 4, 
    '='
  );
  const binary = atob(padded);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes.buffer;
}

3단계: 등록 완료 API (서버)

클라이언트로부터 받은 credential을 검증하고 공개키를 저장한다.

// server.js
app.post("/register/finish", (req, res) => {
  const { username, credential } = req.body;

  const user = db.users.find((u) => u.username === username);

  // 1. Challenge 검증
  const challengeData = db.challenges.get(
    credential.response.clientDataJSON_challenge
  );
  if (!challengeData || 
      challengeData.userId !== user.id || 
      challengeData.type !== "register") {
    return res.status(400).json({ error: "유효하지 않은 challenge" });
  }

  // Challenge 삭제 (일회용)
  db.challenges.delete(credential.response.clientDataJSON_challenge);

  // 2. ClientDataJSON 검증
  const clientDataJSON = JSON.parse(
    Buffer.from(credential.response.clientDataJSON, "base64").toString("utf-8")
  );

  if (clientDataJSON.type !== "webauthn.create") {
    return res.status(400).json({ error: "잘못된 credential type" });
  }

  if (clientDataJSON.origin !== "http://localhost:5173") {
    return res.status(400).json({ error: "잘못된 origin" });
  }

  // 3. 공개키 추출
  let publicKey = credential.response.publicKey;

  if (!publicKey) {
    // attestationObject에서 공개키 추출
    const attestationObject = cbor.decodeFirstSync(
      base64urlToBuffer(credential.response.attestationObject)
    );
    // ... 공개키 추출 로직 (자세한 내용은 GitHub 코드 참고)
  }

  // 4. Credential 저장
  user.credentials.push({
    credentialId: credential.id,
    publicKey: publicKey,
    createdAt: Date.now(),
  });

  res.json({
    success: true,
    message: "Passkey가 성공적으로 등록되었습니다",
    userId: user.id,
  });
});

Passkey 로그인 구현

1단계: 로그인 시작 API (서버)

로그인 요청을 받으면 challenge를 생성하고 인증 옵션을 반환한다.

// server.js
app.post("/login/start", (req, res) => {
  const { username } = req.body; // username은 선택사항

  const challenge = generateChallenge();

  // Challenge 저장
  db.challenges.set(challenge, {
    userId: user ? user.id : null,
    timestamp: Date.now(),
    type: "login",
  });

  // PublicKeyCredentialRequestOptions 생성
  const options = {
    challenge,
    rpId: "localhost",
    allowCredentials: [], // 빈 배열 = Discoverable Credential 사용
    userVerification: "required",
    timeout: 60000,
  };

  res.json(options);
});

Discoverable Credential:
allowCredentials를 빈 배열로 설정하면, 브라우저가 저장된 모든 Passkey를 표시하여 사용자가 선택할 수 있다. 즉, 사용자 이름 없이도 로그인이 가능하다.

2단계: WebAuthn API 호출 (클라이언트)

// client/src/utils/webauthn.js
export async function loginPasskey(username = '') {
  // 1. 서버로부터 challenge와 옵션 받기
  const startResponse = await fetch(`${API_URL}/login/start`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username: username || undefined })
  });

  const options = await startResponse.json();

  // 2. Base64URL 문자열을 ArrayBuffer로 변환
  const publicKeyOptions = {
    ...options,
    challenge: base64urlToBuffer(options.challenge)
  };

  // 3. WebAuthn API 호출 - 생체 인증 시작
  const credential = await navigator.credentials.get({
    publicKey: publicKeyOptions
  });

  // 4. Credential을 서버로 전송하기 위해 직렬화
  const credentialJSON = {
    id: credential.id,
    rawId: bufferToBase64url(credential.rawId),
    type: credential.type,
    response: {
      clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
      authenticatorData: bufferToBase64url(credential.response.authenticatorData),
      signature: bufferToBase64url(credential.response.signature),
      userHandle: credential.response.userHandle 
        ? bufferToBase64url(credential.response.userHandle)
        : null,
      clientDataJSON_challenge: JSON.parse(
        new TextDecoder().decode(base64urlToBuffer(
          bufferToBase64url(credential.response.clientDataJSON)
        ))
      ).challenge
    }
  };

  // 5. 서버로 credential 전송하여 검증
  const finishResponse = await fetch(`${API_URL}/login/finish`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ credential: credentialJSON })
  });

  return await finishResponse.json();
}

3단계: 로그인 완료 API (서버)

서명을 검증하여 로그인을 완료한다.

// server.js
app.post("/login/finish", (req, res) => {
  const { credential } = req.body;

  // 1. Challenge 검증
  const challengeData = db.challenges.get(
    credential.response.clientDataJSON_challenge
  );
  if (!challengeData || challengeData.type !== "login") {
    return res.status(400).json({ error: "유효하지 않은 challenge" });
  }

  db.challenges.delete(credential.response.clientDataJSON_challenge);

  // 2. ClientDataJSON 검증
  const clientDataJSON = JSON.parse(
    Buffer.from(credential.response.clientDataJSON, "base64").toString("utf-8")
  );

  if (clientDataJSON.type !== "webauthn.get") {
    return res.status(400).json({ error: "잘못된 credential type" });
  }

  if (clientDataJSON.origin !== "http://localhost:5173") {
    return res.status(400).json({ error: "잘못된 origin" });
  }

  // 3. Credential ID로 사용자 찾기
  let user = null;
  let userCredential = null;

  for (const u of db.users) {
    const cred = u.credentials.find((c) => c.credentialId === credential.id);
    if (cred) {
      user = u;
      userCredential = cred;
      break;
    }
  }

  if (!user || !userCredential) {
    return res.status(404).json({ error: "등록된 credential을 찾을 수 없습니다" });
  }

  // 4. AuthenticatorData 검증
  const authData = parseAuthenticatorData(
    base64urlToBuffer(credential.response.authenticatorData)
  );

  if (!authData.userPresent || !authData.userVerified) {
    return res.status(400).json({ error: "사용자 인증 실패" });
  }

  // 5. 시그니처 검증
  // 공개키로 서명을 검증하는 복잡한 과정이 필요하다.
  // 자세한 구현은 GitHub 코드의 verifySignature() 함수를 참고하자.
  const isValidSignature = verifySignature(
    userCredential.publicKey,
    credential.response.signature,
    credential.response.authenticatorData,
    credential.response.clientDataJSON
  );

  if (!isValidSignature) {
    return res.status(400).json({ error: "시그니처 검증 실패" });
  }

  // 6. 로그인 성공
  res.json({
    success: true,
    message: "로그인 성공",
    user: {
      id: user.id,
      username: user.username,
    },
  });
});

시그니처 검증 구현: 암호화 알고리즘(ES256, RS256)에 따라 다른 방식으로 검증해야 하며, COSE 키 포맷 파싱 등 복잡한 과정이 필요하다. 자세한 구현은 GitHub 코드server.js에서 verifySignature() 함수를 확인하자.

핵심 포인트

1. Challenge의 역할

Challenge는 Replay attack을 방지하기 위한 일회용 랜덤 값이다. 클라이언트가 서명에 포함시키므로, 이전에 캡처된 응답을 재사용할 수 없다.

// 등록/로그인 시작 시 challenge 생성
const challenge = bufferToBase64url(crypto.randomBytes(32));
db.challenges.set(challenge, { /* ... */ });

// 완료 시 challenge 검증 및 삭제
if (!db.challenges.has(challenge)) {
  return res.status(400).json({ error: "유효하지 않은 challenge" });
}
db.challenges.delete(challenge); // 일회용이므로 즉시 삭제

2. Discoverable Credential

requireResidentKey: true로 설정하면 Passkey가 디바이스에 저장되어, 사용자 이름 없이도 로그인할 수 있다.

// 등록 시
authenticatorSelection: {
  requireResidentKey: true, // Discoverable Credential 활성화
}

// 로그인 시
allowCredentials: [], // 빈 배열 = 모든 저장된 Passkey 허용

3. ArrayBuffer ↔ Base64URL 변환

WebAuthn API는 ArrayBuffer를 사용하지만, HTTP 통신에는 문자열이 필요하다. Base64URL 인코딩은 URL에서 사용할 수 있도록 +, /, = 문자를 대체한다.

// + → -, / → _, = 제거
bufferToBase64url(buffer)
  .replace(/\+/g, '-')
  .replace(/\//g, '_')
  .replace(/=/g, '');

4. 보안 체크리스트

등록 및 로그인 완료 시 반드시 검증해야 할 항목:

  • Challenge 유효성 (저장된 값과 일치, 타입 확인, 일회용)
  • ClientDataJSON의 type 필드 (webauthn.create 또는 webauthn.get)
  • ClientDataJSON의 origin 필드 (허용된 도메인인지)
  • AuthenticatorData의 userPresent 플래그
  • AuthenticatorData의 userVerified 플래그
  • RP ID 해시 (예상된 도메인의 SHA-256 해시와 일치)
  • 시그니처 검증 (로그인 시)

5. HTTPS 요구사항

WebAuthn은 보안상의 이유로 HTTPS 환경에서만 동작한다. 단, localhost는 예외다.

  • https://example.com - 동작
  • http://localhost - 동작
  • http://192.168.1.100 - 동작하지 않음

마무리

Passkey 구현의 핵심 플로우는 다음과 같다:

  1. 등록: challenge 생성 → navigator.credentials.create() → 공개키 저장
  2. 로그인: challenge 생성 → navigator.credentials.get() → 서명 검증

이 데모 프로젝트는 순수 WebAuthn API만을 사용하여 Passkey의 동작 원리를 명확하게 보여준다. 실제 프로덕션 환경에서는 다음을 추가로 고려해야 한다:

  • 영구 데이터베이스 사용 (현재는 메모리 저장)
  • 세션 관리 (JWT 토큰, 쿠키 등)
  • Rate limiting (무차별 대입 공격 방지)
  • HTTPS 인증서
  • 에러 처리 및 로깅

전체 코드는 GitHub 레포지토리에서 확인할 수 있으며, 로컬에서 직접 실행해볼 수 있다.

# 서버 실행
cd server
npm install
npm start

# 클라이언트 실행 (새 터미널)
cd client
npm install
npm run dev

브라우저에서 http://localhost:5173을 열고 Touch ID나 Face ID로 직접 Passkey를 체험해보자!