React Native 앱에 패스키를 붙이며 웹과 달랐던 세 가지
Android·iOS 네이티브 브리지, WebAuthn begin·finish API, origin 설정과 도메인 association 배포를 하나의 패스키 신뢰 관계로 연결한 과정입니다.
웹에서 사용하던 패스키 로그인을 React Native 앱으로 옮기려 하자 JavaScript 코드만 추가해서는 동작하지 않았습니다. Android Credential Manager와 iOS Authentication Services를 네이티브에서 호출해야 했고, 서버는 웹 URL뿐 아니라 앱이 보내는 origin도 신뢰 목록에 포함해야 했습니다. 도메인 검증 파일은 빌드 산출물에 들어가는 것만으로 부족했고 CDN과 스토리지에서 올바른 응답 메타데이터로 제공돼야 했습니다.
이번 작업에서는 패스키의 시작·완료 API, Android·iOS 브리지, React Native 서비스, 도메인 association 배포를 하나의 인증 흐름으로 연결했습니다.
패스키는 앱 한 곳의 기능이 아니었다
패스키 등록과 로그인에는 최소 네 영역이 참여합니다.
sequenceDiagram participant App as React Native 앱 participant Native as OS 패스키 API participant API as 인증 서버 participant Domain as 도메인 검증 App->>API: challenge와 옵션 요청 API-->>App: WebAuthn 옵션 App->>Native: create 또는 get Native-->>App: 서명된 응답 App->>API: 세션 ID와 응답 제출 API-->>App: 등록 결과 또는 로그인 토큰
아래 코드 예시는 설명을 위해 식별자와 운영 설정값을 일반화했습니다.
어느 한 부분이라도 origin, RP ID, 응답 직렬화 규칙이 다르면 사용자는 OS 인증창까지 본 뒤 실패할 수 있습니다.
JavaScript에는 작은 계약만 노출했다
플랫폼별 구현 차이를 화면 코드에 퍼뜨리지 않도록 네이티브 모듈의 공개 인터페이스를 세 메서드로 제한했습니다.
interface PasskeyBridge {
isSupported: () => Promise<boolean>;
create: (optionsJson: string) => Promise<string | CredentialResponse>;
get: (optionsJson: string) => Promise<string | CredentialResponse>;
}
Android는 Kotlin 모듈, iOS는 Swift 모듈로 구현했지만 JavaScript 서비스는 같은 create와 get을 호출합니다. 네이티브 응답이 문자열 또는 객체로 올 수 있어 브리지 경계에서 한 번 정규화했습니다.
const parseCredentialResponse = (response: string | CredentialResponse): CredentialResponse => {
if (typeof response === "string") {
return JSON.parse(response) as CredentialResponse;
}
return response;
};
네이티브 모듈이 빌드에 연결되지 않은 상태도 조용히 기능 없음으로 처리하지 않고 명확한 오류로 만들었습니다. JavaScript 번들만 갱신했지만 네이티브 앱을 다시 설치하지 않은 상황을 구분하기 위해서입니다.
등록과 인증을 begin·finish로 나눴다
패스키는 앱이 임의로 challenge를 만들면 안 됩니다. 서버가 일회성 challenge와 RP 정보를 발급하고, OS가 생성한 서명 응답을 서버가 검증해야 합니다.
등록 흐름은 다음 순서로 구성했습니다.
const begin = await beginRegistration(sessionToken);
const response = await createCredential(begin.data.options);
const finish = await finishRegistration(
{
sessionId: begin.data.sessionId,
response,
deviceLabel,
},
sessionToken
);
로그인도 같은 구조로 authenticate begin → native get → authenticate finish를 따릅니다. 서버가 발급한 sessionId를 완료 요청에 함께 보내 challenge와 응답을 연결했습니다.
사용자 식별자를 입력했는데 allowCredentials가 비어 있다면 OS 호출 전에 등록된 패스키가 없다는 오류를 반환했습니다. 반면 사용자 식별자 없이 패스키 선택 UI를 여는 discoverable credential 흐름은 빈 배열을 허용했습니다.
취소와 실패를 같은 오류로 취급하지 않았다
패스키 선택창에서 사용자가 취소한 경우와 네이티브 모듈 연결 실패는 대응이 다릅니다. 취소와 패스키 없음은 예상 가능한 흐름으로 낮은 수준의 로그를 남기고, JSON 파싱 실패나 모듈 오류는 진단 정보가 필요한 실패로 기록했습니다.
const isExpectedCredentialError = (error: unknown) =>
["CREDENTIAL_NOT_FOUND", "USER_CANCELLED"].includes((error as any)?.code);
로그에는 플랫폼, 응답 타입, 응답 길이, 세션 존재 여부처럼 진단에 필요한 메타데이터만 기록했습니다. credential 응답 원문이나 토큰은 로그에 남기지 않았습니다.
서버의 origin은 웹 URL만으로 끝나지 않았다
WebAuthn 서버 설정은 웹 origin과 Android 앱 origin을 분리해 환경변수로 받고, 중복을 제거한 하나의 허용 목록으로 구성했습니다.
const browserOrigins = parseCsv(process.env.BROWSER_ALLOWED_ORIGINS);
const appOrigins = parseCsv(
process.env.APP_ALLOWED_ORIGINS
);
const allowedOrigins = Array.from(
new Set([...browserOrigins, ...appOrigins])
);
RP ID는 스킴과 포트를 제외한 도메인이고, origin은 웹의 전체 URL 또는 네이티브 앱 origin입니다. 개발 환경의 기본값을 운영에 그대로 사용하지 않도록 운영에서는 명시적인 환경 설정을 요구했습니다.
도메인 association 파일은 배포 응답까지 검증해야 했다
iOS와 Android는 앱과 웹 도메인의 관계를 검증할 수 있어야 합니다. association 파일을 소스에 두는 것만으로는 충분하지 않았습니다. SPA 빌드 과정에서 점으로 시작하는 경로가 빠질 수 있고, 정적 파일 배포 뒤 콘텐츠 타입이나 캐시 정책이 잘못될 수 있습니다.
웹 빌드 설정에서 검증 파일이 산출물에 복사되도록 하고, 배포 워크플로에서는 해당 파일을 application/json과 무캐시 정책으로 다시 업로드했습니다. 이 변경은 앱 코드, 서버 설정, 웹 정적 파일 배포가 모두 인증 기능의 일부라는 점을 보여줬습니다.
소셜 계정 연결과 로그인 완료 로직도 분리했다
같은 시기에 Apple·Google·Kakao 네이티브 로그인과 기존 계정 연결 흐름을 정리했습니다. 플랫폼 SDK가 제공하는 credential을 서버 계정과 연결하되, 로그인 성공 뒤 토큰 저장과 사용자 상태 처리는 공통 완료 로직으로 모았습니다.
이렇게 하면 인증 수단마다 화면과 SDK 호출은 달라도 다음 단계는 공유할 수 있습니다.
- 승인 대기·가입 반려 상태 처리
- 사용자 토큰 저장
- 푸시 토큰 등록
- 최초 화면 이동
- 실패 로그와 사용자 안내
확인된 결과와 한계
React Native 앱, 인증 API, 도메인 association 파일을 함께 변경해 운영 서비스에 반영했습니다.
- Android와 iOS 패스키 네이티브 브리지가 추가됐습니다.
- 등록과 로그인이 서버 challenge 기반 begin·finish 흐름으로 연결됐습니다.
- 웹·Android origin을 환경별로 관리하도록 서버 설정이 분리됐습니다.
- association 파일이 빌드와 정적 파일 배포에서 올바른 형식으로 제공되도록 보완됐습니다.
- 모듈 미연결, 사용자 취소, 패스키 없음, 서버 실패를 구분해 처리했습니다.
패스키는 OS 버전, 기기 잠금 설정, 플랫폼 계정 상태에 영향을 받으므로 비밀번호나 소셜 로그인 같은 복구 경로를 함께 유지해야 합니다.
네이티브 인증은 코드보다 신뢰 관계를 연결하는 일이다
이번 작업에서 얻은 기준은 다음과 같습니다.
- 플랫폼 차이는 얇은 네이티브 브리지 뒤에 숨깁니다.
- challenge 발급과 검증은 서버 세션으로 연결합니다.
- RP ID, 웹 origin, 앱 origin을 서로 다른 값으로 다룹니다.
- association 파일은 저장소 존재가 아니라 실제 HTTP 응답까지 확인합니다.
- 취소 가능한 사용자 흐름과 시스템 오류를 구분합니다.
React Native 패스키의 완성 지점은 네이티브 API 호출이 아니라 앱·서버·도메인이 같은 신뢰 관계를 증명하는 순간입니다.