Cheondi
개발 · 보안

패스키 인증의 서버 검증 경계

패스키의 간단한 로그인 화면 뒤에서 서버가 책임져야 하는 challenge, origin, RP ID와 복구 정책을 정리한 기록입니다.

  • #webauthn
  • #passkeys
  • #authentication
  • #security
  • #api

로그인 화면을 만들다 보면 편한 UX와 안전한 인증이 자주 충돌한다. 비밀번호 규칙을 늘리면 사용자가 힘들고, 자동 로그인을 오래 유지하면 토큰 관리가 어려워진다. 패스키는 이 문제를 꽤 다른 방향으로 푼다. 사용자는 기기의 생체 인증이나 PIN을 사용하고 서버에는 비밀번호 대신 공개키 기반 자격 증명이 연결된다.

처음에는 브라우저에서 navigator.credentials.create()get()을 호출하면 큰 부분이 끝나는 줄 알았다. 하지만 WebAuthn Level 3와 등록 가이드를 읽어보니 브라우저가 편해진 만큼 서버가 확인해야 할 경계는 더 명확해졌다.

패스키는 비밀번호 입력을 없애지만 서버의 인증 판단을 없애지는 않는다. 이번 글에서는 화면보다 서버가 어떤 값을 만들고, 보관하고, 검증해야 하는지를 중심으로 정리했다.

WebAuthn Level 3 — 등록과 인증을 나누는 기준

WebAuthn 문서는 registration ceremony와 authentication ceremony를 분리한다. 등록에서는 authenticator가 새로운 credential key pair를 만들고 공개키를 relying party인 서버에 전달한다. 인증에서는 authenticator가 서버의 challenge에 서명하고 서버가 이미 저장한 공개키로 그 서명을 확인한다.

개인키는 서버로 전달되지 않는다. 브라우저의 JavaScript도 개인키 자체를 읽지 않는다. 이 점 때문에 비밀번호 DB가 유출되는 경우와 공격 표면이 달라진다. 대신 서버는 어떤 credential이 어떤 계정과 RP에 속하는지 정확히 보관해야 한다.

등록
서버 challenge 생성
  → 브라우저 navigator.credentials.create()
  → authenticator가 key pair 생성
  → 서버가 응답 검증 후 public key 저장

인증
서버 challenge 생성
  → 브라우저 navigator.credentials.get()
  → authenticator가 challenge에 서명
  → 서버가 저장한 public key로 검증

두 흐름에서 공통으로 중요한 값은 challenge다. 서버가 예측하기 어려운 값을 만들고 짧은 세션에 묶어두지 않으면, 정상 응답을 나중에 다시 보내는 공격을 구분하기 어렵다. challenge는 화면에서 만든 임의 문자열이 아니라 서버가 발급하고 한 번만 소비하는 인증 상태여야 한다.

Passkey Registration — 브라우저 호출 전후의 상태

web.dev의 등록 가이드는 registration options를 서버에서 만들고 브라우저 결과를 다시 서버에서 검증하는 왕복 흐름을 보여준다. 브라우저 코드는 짧아 보여도 앞뒤의 서버 상태가 빠지면 안전한 등록이 되지 않는다.

const options = await fetch('/api/example/passkeys/options', {
  method: 'POST',
  credentials: 'include'
}).then(response => response.json());

const credential = await navigator.credentials.create({ publicKey: options });

await fetch('/api/example/passkeys/verify', {
  method: 'POST',
  credentials: 'include',
  body: JSON.stringify(serializeCredential(credential))
});

서버는 options를 만들 때 현재 로그인한 계정, RP ID, 허용할 algorithm, challenge, timeout을 결정한다. 검증할 때는 브라우저가 돌려준 값과 세션에 저장해 둔 기대값을 비교한다. 클라이언트가 같이 보내준 expectedOrigin 같은 값을 그대로 믿으면 검증의 의미가 없다.

등록 전 사용자 확인도 필요하다. 이미 로그인한 계정에서 새 패스키를 추가하는 것인지, 새 계정을 만드는 과정인지, 중요한 작업 전에 재인증을 요구할 것인지 정책이 달라진다. 기기를 분실한 공격자가 열린 세션만으로 자기 패스키를 추가할 수 있다면 이후의 인증은 강해도 등록 경계가 약하다.

서버가 보관할 데이터는 대략 다음처럼 생각할 수 있다.

Credential
  credential_id
  user_id
  public_key
  sign_count
  transports
  created_at
  last_used_at
  display_name

display_name은 “회사 노트북”, “휴대전화”처럼 사용자가 나중에 credential을 구분하고 폐기하는 데 필요하다. 보안 기능이라고 해서 관리 UX를 빼면 분실한 기기의 credential을 찾기 어려워진다.

Server Verification — SDK 밖에 남는 기대값

Google의 서버 등록 가이드는 검증해야 할 항목을 구체적으로 나눈다. 실제 구현에서는 검증 라이브러리를 쓰는 편이 맞지만, 라이브러리에 넣는 기대값은 애플리케이션이 올바르게 준비해야 한다.

// 라이브러리마다 실제 API 이름은 다르다.
const result = verifyAuthentication({
  response,
  expectedChallenge: session.passkeyChallenge,
  expectedOrigin: 'https://example.com',
  expectedRpId: 'example.com',
  credential: storedCredential
});

여기서 expectedChallenge를 요청 body에서 꺼내면 안 된다. expectedOrigin을 현재 브라우저가 알려준 값에 맞춰 바꿔서도 안 된다. RP ID와 origin은 배포 환경별로 허용 목록을 관리하고, challenge는 서버 세션이나 짧은 수명의 저장소에서 한 번 소비해야 한다.

내가 특히 조심하고 싶은 건 다음 경계다.

검증 값 잘못된 출처 올바른 기준
challenge 클라이언트 요청 body 서버가 발급한 미사용 challenge
origin 요청이 주장하는 origin 환경별 고정 허용 목록
RP ID 클라이언트가 보낸 domain 서비스가 등록한 relying party
credential 응답 안의 public key 계정에 이미 연결된 서버 저장 값

로그도 같은 원칙으로 줄여야 한다. credential ID 전체, attestation object, 사용자 식별자를 그대로 남기기보다 검증 단계, 결과 코드, 내부 correlation ID만 기록하는 편이 낫다. 인증 오류를 분석하려고 만든 로그가 새로운 민감 정보 저장소가 되면 곤란하다.

Adoption Boundary — 패스키를 붙이는 현실적인 순서

패스키를 도입한다고 기존 로그인을 바로 없앨 수 있는 서비스는 많지 않을 것 같다. 이미 가입한 사용자가 있고, 기기를 잃어버릴 수 있고, 일부 WebView나 브라우저에서는 원하는 UX가 다르게 보일 수 있다. 그래서 처음부터 유일한 인증 수단으로 두기보다 기존 계정에 추가하는 방식이 현실적이다.

내가 생각한 도입 순서는 다음과 같다.

  1. 로그인된 사용자가 패스키를 추가하고 이름을 붙이는 기능
  2. 등록·인증 challenge의 짧은 만료와 일회성 소비
  3. 계정에 연결된 credential 목록과 폐기 기능
  4. 패스키 우선 로그인과 기존 방식 fallback
  5. 기기 분실을 가정한 복구와 중요 작업 재인증
  6. 환경별 origin/RP ID 검증과 감사 로그

복구 정책이 가장 어렵다. 패스키는 phishing-resistant 인증을 제공해도 이메일 복구 링크가 모든 검증을 우회한다면 전체 계정 보안은 그 링크 수준으로 내려간다. 복구에 필요한 증거와 대기 시간, 기존 기기 알림, 새 credential 등록 제한을 함께 봐야 한다.

또 하나는 WebView다. 브라우저와 운영체제의 credential UI를 쓰는 기능이라 앱 내 WebView, 외부 브라우저 전환, 도메인 연결 방식에 따라 사용자 경험이 달라질 수 있다. 구현 전에 지원 환경별로 실제 registration과 authentication을 한 번씩 통과시키는 작은 실험이 필요하다.

패스키에서 가장 인상 깊었던 점은 비밀번호를 없애는 UI보다 검증 책임이 더 잘 드러난다는 것이었다. 브라우저와 authenticator가 복잡한 암호 작업을 맡아주지만, 이 응답을 어느 계정과 세션, 도메인에 연결할지는 여전히 서버의 일이다.

나중에 실제 기능을 붙일 때도 버튼부터 만들기보다 challenge의 생성과 소비, credential 저장 모델, 복구 경계를 먼저 그려보려고 한다. 그 그림이 있어야 편한 로그인과 안전한 인증을 같은 흐름으로 설명할 수 있을 것 같다.


참고한 글과 코드