WebView 앱에서 로그인 문제를 볼 때는 웹 화면만 고쳐서 끝나지 않았다. 쿠키는 어느 저장소에 있는지, 외부 브라우저를 열면 세션이 이어지는지, 앱으로 돌아올 때 어떤 URL을 받는지 같이 봐야 했다. 같은 로그인 페이지라도 Chrome 탭, Android WebView, 네이티브 SDK의 경계가 달랐다.
패스키가 비밀번호 없는 로그인 방식으로 널리 소개되면서 WebView에도 버튼 하나만 붙이면 될지 궁금해졌다. WebAuthn API 호출 자체보다 credential이 어느 앱·도메인에 묶이고 사용자 확인 화면을 누가 띄우는지가 더 중요한 문제였다.
패스키가 확인하는 것
패스키는 서버가 보낸 challenge에 대해 기기의 authenticator가 개인 키로 서명하고, 서버가 등록된 공개 키로 검증하는 방식이다. 개인 키는 서버에 보내지 않는다.
const options = await fetch(
'https://api.example.com/passkeys/authentication/options',
{ credentials: 'include' }
).then(response => response.json());
const credential = await navigator.credentials.get({
publicKey: options.publicKey
});
await fetch('https://api.example.com/passkeys/authentication/verify', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(serializeCredential(credential))
});
예제에서는 challenge와 credential을 전송 가능한 형태로 변환하는 코드가 생략돼 있다. 실제 서버는 challenge의 일회성, origin, RP ID, 사용자 확인 결과, signature counter 정책 등을 검증해야 한다.
화면 컨테이너마다 다른 경계
| 실행 위치 | credential 주체 | 장점 | 확인할 점 |
|---|---|---|---|
| 네이티브 앱 | OS credential API | 앱 수명과 깊게 통합 | 앱-사이트 연결 설정 |
| 시스템 브라우저 | 브라우저와 OS | 웹 표준 지원이 빠름 | 앱 복귀와 세션 교환 |
| WebView | WebView·앱 통합 방식 | 기존 웹 UI 재사용 | 버전, API 지원, origin 경계 |
WebView 안에서 JavaScript API가 보인다고 모든 환경에서 같은 사용자 경험이 보장되지는 않는다. Android 버전, WebView 제공자, 앱의 도메인 연결, credential manager 연동 방식을 함께 확인해야 한다. 인증 UI를 흉내 내는 JavaScript bridge를 직접 만드는 것은 비밀 데이터와 origin 검증 경계를 흐릴 수 있다.
시스템 브라우저를 사용한다면 완료 후 앱으로 돌아오는 값을 그대로 세션으로 쓰지 않는 편이 안전하다.
앱 → 시스템 브라우저 로그인
→ 서버가 짧은 수명의 일회용 code 발급
→ app.example://callback?code=...
→ 앱이 서버에서 code를 세션으로 교환
callback URL에 장기 토큰을 싣지 않고, code는 한 번만 사용할 수 있게 한다. 앱 링크나 universal link의 소유권도 검증해야 다른 앱이 callback을 가로채기 어렵다.
패스키만 있는 화면의 함정
패스키 버튼을 눌렀다가 사용자가 취소하는 것은 오류와 다르다. 다른 기기에 credential이 있거나, 현재 브라우저가 조건을 충족하지 않거나, 계정 복구가 필요한 경우도 있다.
try {
await signInWithPasskey();
} catch (error) {
if (error.name === 'NotAllowedError') {
showMessage('인증이 취소됐거나 시간 제한이 지났습니다.');
showAlternativeLogin();
} else {
reportTechnicalFailure(error.name);
showAlternativeLogin();
}
}
폴백이 비밀번호라면 패스키 화면만 강화하고 끝낼 수 없다. 비밀번호 재설정, 이메일 확인, 고객 지원을 통한 복구가 더 약한 우회 경로가 되지 않는지 같이 봐야 한다.
확인 목록
- RP ID와 실제 서비스 도메인이 일치하는가?
- 등록 challenge와 인증 challenge가 짧은 시간에 한 번만 사용되는가?
- 네이티브·브라우저·WebView 각각에서 지원 여부를 기능 감지하는가?
- 사용자가 취소했을 때 계정 잠금처럼 처리하지 않는가?
- 다른 기기의 패스키를 사용할 수 있는 경로가 있는가?
- 앱 복귀 code가 짧은 수명이며 재사용되지 않는가?
- 기존 로그인과 계정 복구 경로도 같은 위험 수준으로 보호되는가?
- 로그에 challenge, credential 원문, 세션 값이 남지 않는가?
패스키는 피싱에 강한 인증 수단이지만 앱의 세션 관리와 WebView 경계를 대신 설계해 주지는 않는다. 이번에 정리하면서 로그인 버튼보다 앞뒤 경로가 더 중요하다는 걸 다시 확인했다. 어느 컨테이너가 인증을 수행하고, 어느 도메인이 자격 증명을 검증하며, 실패하면 어디로 돌아가는지를 먼저 그려야 했다.
테스트도 등록 성공 한 번으로 끝내면 부족하다. 같은 계정을 다른 기기에서 사용하고, 기기 잠금 방식을 바꾸고, WebView 업데이트 전후를 비교하며, 앱이 백그라운드로 갔다 돌아오는 흐름을 확인해야 한다. 패스키가 없는 계정과 이미 등록된 계정의 화면도 달라야 한다. 인증 성공률뿐 아니라 취소율과 폴백 이동률을 보면 사용자가 실제로 막히는 위치를 찾을 수 있다.