비밀번호와 OTP로 로그인하고 JWT를 발급하던 서비스에 패스키를 추가했다. 서명 검증 API를 붙이기 전에 로그인과 키 관리의 인증 조건부터 정했다. 패스키로 로그인할 때와 새 키를 추가하거나 지울 때 요구할 확인 절차가 달랐기 때문이다.
패스키가 서버에 증명하는 것
패스키는 공개키 암호를 이용하는 인증 수단이다. 개인키는 인증기나 패스키 제공자가 관리한다. 서비스 서버는 개인키를 받지 않고 등록된 공개키로 인증 응답의 서명을 검증한다. 동기화형 패스키는 여러 기기에서 사용할 수 있으므로 개인키가 한 기기에만 머무는 것은 아니다. FIDO Alliance의 패스키 설명도 기기·클라우드·보안 키를 통한 보관 방식을 구분한다.
지문·얼굴·PIN은 키 사용을 허용하는 로컬 사용자 확인 수단이다. 얼굴이나 지문은 서비스 서버로 보내지 않고 인증기 측에서 확인한다. 서비스에는 사용자 확인 결과가 전달된다. WebAuthn의 생체정보 보호 규정에 해당하는 부분이다.
로그인과 키 관리의 조건을 나눴다
로그인 방식과 키 관리 작업에 따라 인증 조건을 나눴다.
비밀번호 로그인: 비밀번호를 확인하고, OTP 사용 계정이면 OTP도 확인한다.
패스키 로그인: 등록된 키의 서명과 로컬 사용자 확인을 검증한다.
패스키 추가·삭제: 본인 JWT에 더해 비밀번호를 다시 확인하고, OTP 사용 계정이면 OTP도 요구한다.
등록과 인증에는 모두 userVerification=required를 적용했다. 서버에서도 응답의 UV 플래그를 검사해 사용자 확인이 수행됐는지 확인했다. W3C 명세의 required는 사용자 확인 결과가 없는 응답을 성공으로 받아들이지 않는 조건이다.
이 조건을 만족한 패스키 로그인에는 OTP를 다시 요구하지 않았다. 이는 이 서비스에서 선택한 정책이다. 다른 서비스에도 그대로 적용할 수 있는 일반적인 기준은 아니다. 키 관리는 앞으로 로그인에 쓸 수단을 바꾸는 작업이다. 유효한 JWT만으로 새 키를 등록하거나 기존 키를 삭제할 수 없도록 별도 재인증을 뒀다.

코드에서도 이 차이가 드러나야 했다. 로그인은 패스키 검증 결과를 기존 로그인 완료 처리에 넘기지만, 키 관리는 비밀번호와 필요한 OTP를 다시 확인한 뒤 작업별 증명을 발급한다. “로그인되어 있음”과 “인증 수단을 바꿀 수 있음”을 같은 조건으로 두지 않았다.
검증 뒤에는 기존 JWT 로그인으로 합류시켰다
구현 환경은 Spring Boot 3.5·Spring Security 6.5 기반 REST 서비스였다. 기존 STATELESS 필터 체인은 유지하고, API 서비스에서 WebAuthn 등록·인증을 검증한 뒤 기존 로그인 완료 처리를 호출했다.
서버는 challenge, RP ID, Origin, 서명과 credential의 소유 관계를 확인했다. 기기에서 계정을 선택하는 로그인이라, 등록된 credential과 user handle의 연결도 검증했다. 이는 WebAuthn 인증 응답 검증 절차의 일부다. 서명이 맞더라도 현재 로그인 가능한 계정인지는 별도로 확인했다.
검증이 끝나면 기존과 같이 access/refresh token과 사용자 정보·메뉴를 반환했다. 프런트엔드의 토큰 저장, 갱신과 다중 탭 동기화도 재사용했다. 패스키 로그인에도 기존 계정 상태와 권한 정책이 적용되도록 했다.
검증 결과를 기존 로그인 완료 처리로 넘기기
아래 코드는 구현 대조 기록을 바탕으로 흐름만 남긴 설명용 발췌다. 회사·제품명과 서비스·메서드 이름은 일반 예제명으로 바꾸고, 주변 설정과 예외 처리는 생략했다. 그대로 복사해 실행하는 완성 예제나 이번 글에서 새로 컴파일한 코드는 아니다.
여기서 attempts.consume는 만료·용도·출처가 맞는 요청을 한 번만 가져오는 단계다. 그다음 저장한 옵션과 브라우저 응답으로 WebAuthn 검증을 수행한다. 검증에 성공한 사용자만 기존 로그인 완료 처리로 넘어간다. 서명 검증을 건너뛰고 request ID만으로 토큰을 발급하는 흐름이 아니다.
계정 상태 확인과 토큰 발급을 기존 완료 처리에 모으니 로그인 수단마다 서로 다른 후처리를 만들 필요가 없었다. 패스키로 인증했다는 이유로 비활성 계정을 허용하거나 다른 권한 목록을 내려주지 않도록 같은 경계를 통과시킨 것이다.
관리 작업은 별도 증명을 발급하기
등록용 증명과 삭제용 증명은 서로 다른 용도로 발급했다. 삭제 증명에는 선택한 credential도 결합한다. 위 발췌의 proofs.issue는 이 결합을 짧게 표현한 예제명이다. 실제 관리 요청에서는 증명을 소비하고 당시 계정 상태가 여전히 같은지도 확인한다. 비밀번호를 한 번 확인했다는 결과를 무기한 또는 다른 작업에 재사용하지 않기 위해서다.
오래 남길 키와 한 번 쓸 상태를 분리했다
공개키, credential ID, 계정 매핑은 PostgreSQL에 영속 저장했다. 서버가 재시작해도 남아야 하고, 키의 소유권과 삭제 상태를 일관되게 관리해야 하는 정보다.
반면 로그인·등록 challenge와 관리 재인증 증명은 Redis에 짧게 보관했다. Spring Security의 기본 등록 옵션 저장소는 HttpSession을 사용하지만, 여기서는 기존 JWT 구조에 로그인 세션 의존성을 추가하지 않기로 했다. STATELESS 필터 체인을 유지하더라도 인증 도중에 필요한 일회용 상태는 따로 관리해야 했다.
만료시간만 설정해서는 동시에 들어온 두 요청의 재사용을 막기 어렵다. 조회와 삭제를 따로 하지 않고 원자적으로 소비해 한 요청만 사용하도록 했다. 관리 증명에는 사용자·작업·삭제 대상을 결합해 다른 작업에 돌려 쓸 수 없게 했다. 이미 소비됐을 수 있는 요청은 토큰 갱신 뒤 자동 재전송하는 대상에서도 제외했다.
재인증 API 밖의 규칙도 맞췄다
비밀번호 실패 제한은 AuthenticationManager 밖의 로그인 서비스에 있었다. 관리 재인증에도 같은 제한을 적용하려고 비밀번호 확인과 실패 제한을 공통 서비스로 묶었다.
재인증 뒤 비밀번호나 OTP 상태가 바뀌면 이전 관리 증명을 쓸 수 없게 했다. 삭제와 인증이 겹칠 때 삭제한 키가 다시 저장되지 않도록 현재 DB 행과 잠금을 기준으로 처리했다. 기존 비밀번호·OTP 로그인 경로는 유지했다. 다만 마지막 패스키 전용 차단 화면이나 패스키 전용 계정 복구를 구현했다는 근거는 확인하지 못했다. 모든 로그인 수단을 잃었을 때의 복구 정책은 별도로 확인해야 한다.
확인한 범위와 남은 범위
실제 서버·PostgreSQL·Redis와 가상 인증기를 연결한 환경에서 등록, 이메일 없는 로그인, 보호 API, refresh, 다중 탭, 재인증 삭제와 삭제 키 거절을 확인했다. 다만 외부 감사 전송은 mock으로 대체한 테스트였다.
Windows Chrome에서 1Password로 등록하고 로그아웃한 뒤 패스키로 로그인하는 흐름은 수동 검증 결과를 전달받았다. Windows Hello 자체와 실제 모바일 환경의 인수 검증은 이 기록 시점에 끝나지 않았다.
로그인에는 등록된 패스키 검증을, 키 추가·삭제에는 별도 재인증을 적용했다. 이 정책에 맞춰 기존 JWT 발급 흐름에 연결하고, 영속 키 정보와 일회용 인증 상태를 나눠 저장했다. 키 분실 후 복구가 어디까지 가능한지는 정상 로그인과 별도의 검증 과제로 남겼다.
테스트 대조 기록에는 비OTP·OTP 로그인, 비활성 계정의 토큰 발급 거절, 계정 상태 변경 후 기존 관리 증명 거절을 다루는 테스트 정의가 있다. 실행 기록에는 패스키 관련 CI 84건 통과와 비OTP·OTP 가상 인증기 브라우저 흐름 각각 1건 통과가 남아 있다. 서로 겹치는 범위이므로 합산하지 않았다. 테스트 정의의 존재, 당시 실행 결과, 실제 사용자 기기 확인은 다른 근거이며, 이번 글 보강 중 제품 테스트를 새로 실행한 것은 아니다.
답글 남기기