패스키로 로그인하면 지문이나 얼굴을 확인한 뒤 화면이 넘어간다. 비밀번호를 입력하지 않았는데 서버는 어떻게 내 계정으로 로그인시켜 주는 걸까.
앞선 글에서는 기존 JWT 서비스에 패스키를 붙일 때 로그인과 키 관리의 인증 정책을 나눈 이유를 정리했다. 이번에는 그 판단의 바탕이 되는 등록과 로그인 과정을 따라가 보려 한다. 기본 원리는 WebAuthn의 동작으로 설명하고, 요청·응답과 서버 코드 예시는 첫 글의 구현 대조 기록에서 가져와 구분했다. 서비스별 API 모양과 정책이 WebAuthn 표준 자체인 것은 아니다.
처음 등록할 때 계정과 키가 연결된다
패스키에는 짝을 이루는 공개키와 개인키가 사용된다. 개인키는 서명을 만드는 데 쓰이고, 공개키는 그 서명을 확인하는 데 쓰인다. 서버가 공개키를 가지고 있어도 그것만으로 사용자를 대신해 로그인 서명을 만들 수는 없다. Google 패스키 설명
처음 패스키를 추가할 때는 서비스가 어느 계정에 키를 등록할지 알아야 한다. 기존 계정에 추가한다면 로그인이나 재인증처럼 등록 권한을 확인하는 절차가 먼저 필요하다. 다른 사람의 계정에 내 키를 붙일 수 있다면 이후의 서명 검증이 정확해도 소용이 없기 때문이다.
서버는 등록 요청용 challenge와 서비스·계정 정보를 브라우저에 보낸다. 브라우저가 WebAuthn의 create()를 호출하면 인증기가 키 쌍을 만들고 등록 응답을 돌려준다. 여기서 인증기는 운영체제나 패스키 제공자 등이 제공하는, 키를 만들고 사용하는 구성 요소이다.
서버는 돌아온 challenge가 발급한 값과 같은지, 요청한 사이트와 서비스의 정보가 맞는지 등 등록 응답을 검증한다. 그다음 credential ID와 공개키를 해당 계정에 연결해 저장한다. credential ID는 이후 어떤 등록 정보를 사용할지 찾는 식별자이다. MDN 등록 흐름
이 연결이 있어야 다음 로그인에서 검증에 성공한 키가 어느 계정의 것인지 알 수 있다. 공개키 자체에 사람의 신원이 들어 있는 것은 아니다.
등록 API에서는 어떤 값이 오갈까
이 서비스는 관리 재인증을 먼저 거친 뒤 등록 옵션을 받는 구조였다. 아래는 요청·응답에서 일부 필드만 남긴 형태다. 경로와 표시 이름은 일반화했고, 꺾쇠 안의 값은 실제 인증값이 아닌 자리표시자다. 필수 알고리즘 목록 등 생략한 필드가 있어 이 내용만으로 등록 요청을 구성할 수는 없다.
브라우저가 create()로 만든 등록 응답은 같은 request ID와 함께 verify 단계로 보낸다. request ID는 서비스가 발급한 요청과 저장 옵션을 찾는 식별자이고, credential ID는 인증기가 만든 키 등록을 식별한다. 둘은 같은 값이 아니다.
로그인할 때는 매번 새로운 응답을 받는다
등록을 마친 뒤 로그인을 시작하면 서버가 새로운 challenge를 발급한다. 예측하기 어려운 임의의 값으로, 이번 인증 시도에 대한 응답인지 구분하기 위해 사용한다.
브라우저는 이 값을 포함한 옵션으로 get()을 호출한다. 사용자가 사용할 패스키를 고르고 필요한 사용자 확인을 마치면, 인증기는 해당 개인키로 서명한 인증 응답을 만든다. 브라우저는 이 응답을 서버로 전달한다.
설명할 때 흔히 “challenge에 서명한다”고 줄여 말하지만, 실제 서명 대상은 인증기 데이터(authenticatorData)와 클라이언트 데이터(clientDataJSON)의 해시를 이어 붙인 값이다. challenge와 origin은 클라이언트 데이터에 들어 있고, 인증기 데이터에는 RP ID의 해시와 사용자 확인 여부 등이 들어 있다. WebAuthn 명세
서버는 저장한 공개키로 서명을 확인한다. 동시에 challenge가 이번 요청에 발급한 값인지, origin과 RP ID가 기대한 값인지, 요구한 사용자 확인이 이루어졌는지도 검증한다. 브라우저가 성공 응답을 반환했다는 사실만으로 로그인을 허용하지 않는다. Google 서버 검증 가이드
challenge는 짧은 유효 기간을 두고 해당 인증 시도에 묶어 관리해야 한다. 이미 처리한 응답을 다시 받아들이면 안 된다. 서버가 매번 새 값을 발급하고 사용한 값을 재사용하지 않아야, 이전 로그인 응답을 복사해 다시 제출하는 시도를 막을 수 있다. Google 서버 검증 가이드

로그인 응답은 등록 응답과 다르다
이메일을 먼저 입력하지 않는 이 서비스에서는 로그인 옵션을 받은 뒤, 브라우저에서 선택한 패스키의 assertion을 보냈다. 등록 응답의 attestationObject와 달리 인증 응답에는 authenticatorData와 signature 등이 들어간다. 아래 역시 주요 필드만 남긴 설명용 계약이다.
HTTP에서는 바이트 데이터를 base64url 문자열로 표현한다. 프런트엔드는 서버 옵션의 바이트 필드를 WebAuthn API가 받는 형태로 변환하고, API가 돌려준 바이트 값을 다시 JSON으로 직렬화한다. 이 표현 변환은 서버의 서명 검증을 대신하지 않는다. userHandle 요구 여부도 인증 흐름에 따라 달라지며, 여기서는 계정을 먼저 지정하지 않는 정책에 맞춰 저장된 계정 연결과 대조했다.
지문은 서버에 보내는 비밀번호가 아니다
지문이나 얼굴, 기기 PIN은 개인키 사용을 허용하기 위한 사용자 확인 수단이다. WebAuthn에서는 이를 UV(User Verification)라고 부른다. 서버가 지문 원본을 받아 저장된 지문과 대조하는 방식은 아니다.
생체정보 처리는 기기 안에서 이루어지고, 서비스에는 서명과 사용자 확인 결과가 전달된다. 서버는 보통 사용자가 지문을 썼는지 얼굴을 썼는지보다, 요구한 사용자 확인이 수행됐는지를 확인한다. 생체인증을 쓸 수 없는 환경에서는 PIN 같은 다른 수단이 사용될 수도 있다. FIDO 패스키 FAQ
개인키의 보관 방식도 나누어 볼 필요가 있다. 기기에 묶인 패스키가 있는 반면, 같은 제공자를 사용하는 여러 기기에서 쓸 수 있도록 동기화되는 패스키도 있다. 동기화형은 종단간 암호화된 형태로 제공자의 동기화 체계를 이용한다. 그래서 “개인키는 최초 기기 밖으로 절대 나가지 않는다”는 설명은 모든 패스키에 맞지 않는다. 로그인하는 서비스가 개인키를 받아 보관하지 않는다는 점과, 제공자가 키를 동기화한다는 점은 구분해야 한다. FIDO 패스키 FAQ
가짜 사이트에서 같은 키를 쓸 수 없는 이유
비밀번호는 사람이 입력할 곳을 고른다. 진짜 사이트와 비슷한 화면에 같은 문자열을 입력할 수도 있다. 패스키에는 사용할 수 있는 서비스 범위가 정해져 있고, 브라우저와 인증기가 이 범위를 확인한다. Google 패스키 설명
RP ID는 패스키가 연결되는 서비스의 도메인 기반 식별자이고, origin은 요청을 시작한 웹 출처로 스킴·호스트·포트를 포함한다. 서버는 허용한 origin과 기대한 RP ID에 맞는 응답인지 확인해야 한다. RP ID와 origin을 같은 문자열로만 생각하면 서브도메인 등을 사용하는 구성에서 헷갈리기 쉽다. WebAuthn 명세
예를 들어 전혀 다른 도메인에 로그인 화면을 그대로 복사해도, 그 사이트가 원래 서비스의 패스키를 자기 것처럼 사용할 수는 없다. 이 사이트 연결과 서명 검증이 패스키의 피싱 저항성을 만든다. Google 패스키 설명
그렇다고 계정의 모든 공격 경로가 사라지는 것은 아니다. 이미 발급된 세션을 탈취당하거나 계정 복구 절차가 뚫리는 문제는 별도로 남는다. 패스키 도입과 함께 등록·삭제·복구 경로도 살펴야 하는 이유이다.
라이브러리가 검증할 것과 서비스가 책임질 것
서명 검증을 직접 구현하는 대신 WebAuthn 라이브러리를 사용했지만, 라이브러리를 호출했다는 사실만으로 서비스의 인증 정책까지 완성되지는 않는다. 기대하는 challenge·RP ID·허용 origin·UV 조건을 올바르게 전달하고, 그 전후의 상태를 서비스가 관리해야 한다.
| 경계 | 책임을 둔 곳 |
|---|---|
| 서명과 WebAuthn 응답의 검증 | 라이브러리에 저장된 요청 옵션과 credential을 전달해 검증한다. 기대값 없이 서명만 확인하는 단계가 아니다. |
| challenge 비교와 재사용 방지 | 응답 속 challenge가 기대값과 일치하는지 검증하고, 서비스는 그 기대값을 저장한 요청의 용도·만료·원자적 소비를 관리한다. |
| RP ID·origin·UV 정책 | 라이브러리에 기대 조건을 전달한다. 서비스도 설정과 저장된 옵션, 요청 출처가 해당 인증 시도와 맞는지 확인한다. |
| credential과 계정의 연결 | 저장된 credential·user handle·사용자 관계를 대조하고 삭제된 등록을 거절한다. |
| 로그인 허용과 토큰 발급 | 계정 활성 상태와 기존 권한 정책을 확인한 뒤 서비스가 JWT를 발급한다. |
아래 코드는 구현 대조 기록을 바탕으로 흐름만 남긴 설명용 발췌다. 회사·제품명과 서비스·메서드 이름은 일반 예제명으로 바꾸고, 주변 설정과 예외 처리는 생략했다. 그대로 복사해 실행하는 완성 예제나 이번 글에서 새로 컴파일한 코드는 아니다.
request ID가 존재하고 한 번 소비됐다는 사실만으로 challenge 검증이 끝나는 것은 아니다. 저장한 옵션의 challenge를 기대값으로 삼아, 서명으로 보호된 클라이언트 데이터의 값과 대조하는 WebAuthn 검증까지 통과해야 한다. 일회용 저장소와 서명 검증은 서로 대체하는 장치가 아니라 함께 필요한 경계다. Google 서버 인증 가이드
구현 대조 기록의 테스트 정의에는 challenge·origin·RP ID·UV·서명·user handle을 바꾼 응답의 거절, 동일 요청의 동시 소비, 만료된 요청의 거절이 포함되어 있다. 당시 실제 PostgreSQL·Redis를 연결한 인증 테스트와 가상 인증기 실행 기록은 있지만, 테스트 파일이 있다는 이유만으로 모든 기기의 인수가 끝났다고 쓰지는 않았다. 이번 보강에서 제품 테스트를 새로 실행한 것도 아니다.
검증을 마친 다음에는 기존 로그인 흐름으로
패스키 인증이 성공하면 서버는 연결된 계정을 확인하고 로그인 상태를 만들어 준다. JWT를 쓰는 서비스라면 이 지점에서 토큰을 발급하고, 이후 API 요청은 기존 방식대로 처리할 수 있다.
패스키의 개인키는 사용자의 인증 응답을 만드는 키이고, 서버의 JWT 서명키는 서버가 토큰을 발급하는 키이다. 역할도 보관 주체도 다르다. 패스키를 도입했다고 매 API 요청마다 지문을 확인하거나 WebAuthn 서명을 받아야 하는 것은 아니다.
이렇게 보면 앞선 글에서 나눈 경계도 분명해진다. 로그인은 등록된 키를 사용할 수 있음을 확인하는 과정이고, 패스키 관리는 앞으로 로그인에 사용할 키를 바꾸는 과정이다. 새 키를 어느 계정에 연결할지 결정하는 순간에는 별도의 신중한 확인이 필요하다.
답글 남기기