본문 바로가기

카테고리 없음

Passkey(WebAuthn) 로그인 구현 가이드: 비밀번호 없이 등록·로그인·백업 플로우 전체 정리

반응형

비밀번호가 문제를 만들 때, Passkey가 답이 되는 이유

비밀번호는 잊히고, 재사용되고, 유출됩니다. 이로 인해 로그인 실패, 계정보안 이슈, 복구 비용이 누적되고, 사용자 경험은 매번 나빠집니다.

Passkey(WebAuthn)는 기기 생체인증과 공개키 암호화를 활용해 비밀번호 자체를 없앱니다. 결과적으로 “등록–로그인–백업” 전 과정을 단순화하면서 피싱·재사용 위험을 크게 줄일 수 있습니다.

이 글은 왜 Passkey를 선택해야 하는지에서 출발해, 어떤 데이터를 저장하고 어떤 브라우저 API를 호출해야 하는지까지 흐름을 끊기지 않게 설명합니다. 초보자도 바로 설계할 수 있도록 등록, 로그인, 기기 변경·백업 플로우를 하나의 시나리오로 정리합니다.

실무에서는 레거시 계정과의 호환, 다중 기기 동기화, 복구 수단이 핵심입니다. 여기서는 최소 구현 체크리스트와 주의점을 함께 제시해, 팀이 안전하게 롤아웃하는 데 필요한 기준선을 제공합니다.

Passkey는 무엇이고, 어디에 저장되며 어떻게 동작하나요

온라인 쇼핑몰 회원가입을 예로 들면, Passkey 등록 시 서버는 난수 기반 챌린지를 보내고 브라우저가 기기 보안칩에서 키 쌍을 만듭니다. 공개키는 서버에 저장되고, 개인키는 기기 내부에만 남아 생체인증으로만 사용할 수 있습니다.

로그인 때는 서버가 다시 챌린지를 보내고, 사용자는 지문이나 PIN으로 개인키 사용을 허가합니다. 그러면 브라우저가 챌린지에 서명해 돌려주고 서버는 저장된 공개키로 서명을 검증해 사용자를 인증합니다.

여기서 중요한 차이는 비밀번호처럼 서버에 비밀을 두지 않는다는 점입니다. 피싱 사이트에 접속하더라도 도메인이 다르면 서명이 성립하지 않아 공격이 어렵습니다.

실무 사례로 모바일과 데스크톱을 함께 쓰는 사용자는 플랫폼 Passkey(기기 내 저장)와 동기화 Passkey(계정 동기화) 중 어느 쪽이 활성화되어 있는지에 따라 경험이 달라집니다. 조직 계정처럼 공유가 민감한 환경은 기기 기반 Passkey를 기본으로 하고, 분실 대비용으로 보조 복구 수단(이메일 링크, 일회용 코드)을 별도 채널로 두면 안전합니다.

WebAuthn 등록·로그인·복구 한눈에 보기

서버는 rpId(도메인), user.id(바이트), challenge(난수), 알고리즘을 만들고, 프론트는 navigator.credentials.create/get 호출 전 base64url↔ArrayBuffer 변환을 처리합니다.

등록 단계에서 공개키, credentialId, 사용자별 signCount를 저장하며 rpId·origin 일치를 검증합니다. 실패 사유를 구분해 로깅하면 추적이 쉬워집니다.

로그인은 서버가 challenge와 allowCredentials(등록된 credentialId 목록)를 내려주고, 브라우저가 서명을 반환합니다. 서버는 서명 검증 후 signCount 또는 uv 플래그로 리플레이를 점검합니다.

복구는 1회용 복구 코드와 MFA(이메일/OTP)로 제한 세션을 발급하고 Passkey 재등록을 유도합니다. 주요 브라우저·OS에서 UX와 에러 코드를 사전 점검하세요.

핵심은 rpId·origin 일치, challenge 일회성, 서명 검증과 signCount 확인입니다.

아래 코드는 최소 흐름입니다. 실제 키/인코딩은 유틸로 분리하고, 파라미터는 최신 스펙을 공식 문서에서 확인하세요.

// 서버: 등록 시작
app.post('/webauthn/register/start', (req, res) => {
  const challenge = randomBytes(32);
  const user = { id: toBytes(req.body.userId), name: req.body.email, displayName: req.body.name };
  const options = {
    rp: { name: 'Example', id: 'example.com' },
    user,
    challenge,
    pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
    authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
    timeout: 60000
  };
  saveChallenge(req.body.userId, challenge);
  res.json(toBase64url(options));
});

// 프론트: 등록
const opts = fromBase64url(await (await fetch('/webauthn/register/start')).json());
const cred = await navigator.credentials.create({ publicKey: opts });
await fetch('/webauthn/register/finish', { method: 'POST', body: toBase64url(cred) });

// 서버: 로그인 시작/검증도 유사(challenge 생성 → get() → 서명 검증)

흔한 함정과 선택 기준: rpId, UV, 동기화, 백업

가장 잦은 실수는 rpId와 origin 불일치입니다. 서브도메인, 프록시, 로컬 개발 도메인이 섞이면 검증이 실패하니 “사용자에게 보이는 도메인 = rpId = 쿠키 도메인”을 고정하세요.

User Verification(UV)와 User Presence(UP)를 혼동하는 경우도 많습니다. 결제·권한 변경은 UV 필수로, 단순 로그인은 정책에 따라 단계적 적용을 권장합니다.

플랫폼 Passkey(기기 한정)와 동기화 Passkey(계정 간 복제)의 선택은 조직 정책이 좌우합니다. 공유 기기·규제 환경은 플랫폼 고정, 개인 서비스·크로스 디바이스 편의는 동기화 허용이 현실적입니다.

백업 수단은 중복과 격리가 핵심입니다. 이메일/OTP·1회용 복구 코드·보조 인증자(2차 Passkey) 중 최소 2가지를 분리 채널로 제공하고, 재등록 시 과도한 권한을 즉시 부여하지 마세요.

실무 체크리스트:
- rpId·origin·TLS 강제, 쿠키 SameSite=Lax/Strict 재검토
- ALG/attestation 정책 명시, unknown 알고리즘 거부
- allowCredentials 과소/과다 지정 방지, 다계정 브라우저 프로필 고려
- signCount·backup flags·UV 결과 로깅, 비정상 패턴 알림
- 기기 분실 신고 흐름, 복구 시도 횟수·쿨다운 제한
- 운영 시점마다 브라우저/OS 지원 범위와 에러 코드를 문서로 최신화

마무리: 핵심만 챙기고 바로 착수하기

이 글의 핵심은 rpId·origin 정합성, 챌린지 생성과 서명 검증, UV 정책, 그리고 복구 수단의 이중화입니다. 비밀번호 없이도 등록–로그인–백업 전 과정을 안전하게 닫는 것이 목표입니다.

바로 시작하려면 먼저 최소 범위의 PoC를 만드세요. 하나의 도메인에서 등록과 로그인을 왕복하고, 실패 로그로 rpId·UV·오류 코드를 구분해 수집합니다.

실행 체크리스트:
- 서버: 난수 챌린지 생성, rpId 고정, 공개키·credentialId·signCount 저장
- 프론트: base64url↔ArrayBuffer 변환, navigator.credentials.create/get 호출
- 보안: UV 필요한 경로(결제/권한변경) 지정, 재플레이 방지(signCount 또는 uv 플래그) 검증
- 복구: 이메일/OTP + 복구 코드 2중 채널, 재등록 세션은 제한 권한으로 발급

다음 단계로 레거시 계정과 병행 운영(패스워드+Passkey), 동기화 Passkey 허용 범위, 조직 정책(공유 기기 차단)을 결정하세요. 운영 환경에 배포하기 전, 최신 브라우저·OS별 WebAuthn 동작과 정책 옵션은 공식 문서에서 다시 확인하는 편이 안전합니다.

장바구니 유지 위해 Passkey 왕복 최소화

장바구니를 끊지 않고 결제하려면 등록·로그인 모두에서 세션 쿠키의 도메인(rpId)과 쿠키 설정이 일치해야 합니다. 실패의 대부분은 도메인 정합성과 base64url 변환에서 발생합니다.

아래 예시는 브라우저에서 base64url↔ArrayBuffer 변환과 navigator.credentials.create/get의 최소 흐름을 보여줍니다. 구조 설명용 예시입니다.

// base64url <-> ArrayBuffer
const b64uToBuf = (s) => Uint8Array.from(atob(s.replace(/-/g,'+').replace(/_/g,'/')), c=>c.charCodeAt(0));
const bufToB64u = (b) => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');

// 등록
async function registerPasskey() {
  const opts = await fetch('/webauthn/register/options', { credentials:'include' }).then(r=>r.json());
  opts.publicKey.challenge = b64uToBuf(opts.publicKey.challenge);
  opts.publicKey.user.id = b64uToBuf(opts.publicKey.user.id);
  const cred = await navigator.credentials.create(opts);
  const att = {
    id: cred.id,
    rawId: bufToB64u(cred.rawId),
    response: {
      clientDataJSON: bufToB64u(cred.response.clientDataJSON),
      attestationObject: bufToB64u(cred.response.attestationObject)
    },
    type: cred.type
  };
  await fetch('/webauthn/register/verify', { method:'POST', credentials:'include', headers:{'Content-Type':'application/json'}, body: JSON.stringify(att) });
}

// 로그인
async function loginPasskey() {
  const opts = await fetch('/webauthn/login/options', { credentials:'include' }).then(r=>r.json());
  opts.publicKey.challenge = b64uToBuf(opts.publicKey.challenge);
  opts.publicKey.allowCredentials = opts.publicKey.allowCredentials?.map(c=>({ ...c, id: b64uToBuf(c.id) })) ?? [];
  const cred = await navigator.credentials.get(opts);
  const assn = {
    id: cred.id,
    rawId: bufToB64u(cred.rawId),
    response: {
      clientDataJSON: bufToB64u(cred.response.clientDataJSON),
      authenticatorData: bufToB64u(cred.response.authenticatorData),
      signature: bufToB64u(cred.response.signature),
      userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
    },
    type: cred.type
  };
  await fetch('/webauthn/login/verify', { method:'POST', credentials:'include', headers:{'Content-Type':'application/json'}, body: JSON.stringify(assn) });
}

체크포인트:
- challenge, user.id, credentialId는 ArrayBuffer로 변환해 WebAuthn 호출
- 서버 전송은 base64url로 인코딩
- 쿠키는 SameSite=None; Secure; 도메인 고정; HTTPS 강제
- 등록→로그인 사이 서브도메인 변경·리다이렉트 최소화

반응형