이 글에서는 실제 동작하는 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 구현의 핵심 플로우는 다음과 같다:
- 등록: challenge 생성 →
navigator.credentials.create()→ 공개키 저장 - 로그인: 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를 체험해보자!
'WebCommon' 카테고리의 다른 글
| Passkey 이해하기 1 - 개념과 동작 원리 (1) | 2025.12.24 |
|---|---|
| Access Token과 Refresh Token을 활용한 세션 관리 (0) | 2025.12.08 |
| GET, POST 뭐가 더 안전한 요청일까? (0) | 2025.12.05 |
| CLOUDFLARE로 이미지 업로드 (4) | 2024.09.13 |
| CSP 'upgrade-insecure-requests' 이슈 (1) | 2024.07.05 |