웹을 그대로 감싸는 하이브리드 앱(React Native + WebView) 구조에서, 웹 안에 이미 구현된 소셜 로그인을 그대로 쓰려다 구글 로그인만 막히는 문제를 겪었다. 카카오·네이버·애플은 멀쩡히 되는데 구글만 "이 앱은 Google 정책을 준수하지 않습니다"라는 403 페이지를 띄웠다.
사실 이 문제는 처음이 아니었다. 예전 프로젝트에서도 똑같이 겪었는데, 그때는 User-Agent를 조작해 embedded WebView임을 감춰서 구글 로그인이 WebView 안에서 그대로 동작하게 만들었다. 당장 돌아가긴 했지만, 구글이 명시적으로 막아둔 걸 속여서 통과시키는 방식이라 정책 위반이자 언제 막혀도 이상하지 않은 임시방편이었다. 그래서 이번에는 같은 문제를 정석대로 — 구글이 실제로 허용하는 경로로 — 풀어보고 싶었고, 그 과정을 기록으로 남긴 것이 이 글이다.
결론부터 말하면 이건 버그가 아니라 구글의 보안 정책이고, 정석적인 해결은 "구글 인증 화면만 앱 밖(외부 브라우저)에서 열고, 결과만 다시 앱으로 가져오는" 우회 구조를 만드는 것이다. 이 글은 그 원인과 해결 과정을 정리한 것이다.
아래 코드는 모두 실제 구현을 일반화한 예제 코드다. 스킴(
myapp://), 도메인(example.com) 등은 상황에 맞게 바꿔 쓰면 된다.
앱은 원격 웹(https://example.com/)을 react-native-webview로 띄우는 하이브리드 구조.
웹에는 카카오/네이버/구글/애플 소셜 로그인이 이미 구현돼 있고, 로그인 버튼을 누르면 각 provider의 OAuth 인증 URL로 이동한다.
구글 로그인 버튼만 누르면 인증 화면 대신 403 에러 페이지가 뜬다:
이 앱은 Google 정책을 준수하지 않습니다 (This browser or app may not be secure)
같은 웹을 데스크톱/모바일 브라우저에서 열면 구글 로그인이 정상 동작한다. 오직 앱 WebView 안에서만 실패한다.
즉 웹 코드는 정상이고, "WebView 안에서 구글 인증을 여는 것" 자체가 문제였다.
구글은 보안상의 이유로 embedded WebView(iOS WKWebView, Android WebView) 내부에서 시작되는 OAuth 인증 요청을 차단한다. 서버가 요청의 User-Agent를 보고 embedded WebView라고 판단하면 disallowed_useragent 에러로 거부한다.
이유는 명확하다. embedded WebView는 이를 띄운 네이티브 앱이 자바스크립트 주입·쿠키 접근·입력값 가로채기가 가능해서, 사용자의 구글 자격증명을 앱이 탈취할 수 있는 통로가 된다. 그래서 구글은 이 경로를 아예 막았다. (2016년 발표, 이후 지속 강화)
핵심은 앱 코드로 "설득"할 방법이 없다는 것이다. User-Agent를 조작해 우회하는 건 정책 위반이자 언제든 막히는 임시방편이라 선택지가 아니다.
구글이 명시적으로 허용하는 것은 시스템이 제공하는 안전한 브라우저 탭이다:
SFSafariViewController / ASWebAuthenticationSession이들은 앱과 프로세스가 분리돼 있어 앱이 내용을 훔쳐볼 수 없다. 그래서 "구글 인증만 이 외부 브라우저에서 열자"가 유일한 정답이 된다.
이들 provider는 embedded WebView OAuth를 (구글만큼 엄격하게) 차단하지 않기 때문이다. 그래서 같은 구조인데도 구글 하나만 걸렸다.
가장 신경 써야 했던 제약이 하나 있었다. 토큰 교환(인가 code → access token)이 WebView 안의 클라이언트에서 일어나고, 토큰이 WebView의 스토리지에 저장된다는 점이다.
그래서 단순히 "외부 브라우저에서 구글 로그인 → 외부 브라우저에서 토큰 교환"으로 끝내면, 토큰이 외부 브라우저에 저장돼서 정작 앱 WebView는 여전히 로그아웃 상태가 된다.
따라서 역할을 이렇게 나눴다:
이를 위해 외부 브라우저는 인증 후 받은 인가 code만 앱으로 되돌려주고(딥링크), 앱은 그 code를 다시 WebView에 넘겨 기존 웹 콜백 로직이 WebView 컨텍스트에서 교환하게 했다.
[WebView] 구글 로그인 버튼 클릭
│ → accounts.google.com/o/oauth2/... 로 이동 시도
▼
[네이티브] onShouldStartLoadWithRequest 에서 구글 인증 URL 감지
│ → WebView 로드 차단(return false)
│ → 외부 브라우저(openAuthSessionAsync)로 동일 URL 오픈
▼
[외부 브라우저] 사용자 구글 인증 완료
│ → 구글이 웹 콜백으로 리다이렉트: https://example.com/callback?provider=GOOGLE&code=...
▼
[웹 /callback] "외부 relay 브라우저"임을 감지
│ → 토큰 교환하지 않고 code를 앱 딥링크로 튕김: myapp://oauth-callback?provider=GOOGLE&code=...
▼
[네이티브] openAuthSessionAsync 가 딥링크를 잡아 code 회수 → 외부 브라우저 자동 종료
│ → WebView 를 https://example.com/callback?provider=GOOGLE&code=... 로 이동
▼
[WebView /callback] 기존 로직대로 code→token 교환 & 세션 저장 (완료)
여기서 중요한 포인트: 네이티브가 인증 URL을 return false로 가로채기만 하고 WebView는 로그인 페이지에 그대로 머문다. 덕분에 WebView의 sessionStorage(로그인 버튼 클릭 시 심어둔 CSRF state 등)가 보존되고, 나중에 재진입할 때 그대로 재사용된다.
react-native-webview의 onShouldStartLoadWithRequest에서 구글 인증 URL만 골라내 외부 브라우저로 위임한다. 나머지 URL(다른 소셜 로그인, 본인인증 딥링크 등)은 기존 동작을 그대로 둔다.
// useGoogleOAuthRedirect.ts (예제)
import { useCallback, type RefObject } from "react";
import * as WebBrowser from "expo-web-browser";
import * as Linking from "expo-linking";
import type WebView from "react-native-webview";
// 인터셉트 대상: 구글 OAuth 인가 엔드포인트만. 다른 provider는 건드리지 않는다.
const GOOGLE_AUTH_URL_PREFIX = "https://accounts.google.com/o/oauth2/";
// 웹 relay가 code를 되돌려줄 앱 딥링크. 앱 scheme과 일치해야 한다.
const OAUTH_REDIRECT_URL = "myapp://oauth-callback";
const WEB_ORIGIN = "https://example.com";
export function useGoogleOAuthRedirect(webViewRef: RefObject<WebView | null>) {
const handleGoogleAuth = useCallback(
async (googleAuthUrl: string) => {
// 구글이 허용하는 외부 브라우저(iOS ASWebAuthenticationSession /
// Android Chrome Custom Tabs)에서 인증 화면을 연다.
const result = await WebBrowser.openAuthSessionAsync(
googleAuthUrl,
OAUTH_REDIRECT_URL, // 이 딥링크로 돌아오면 세션이 완료되고 result.url 로 반환
);
// 사용자가 브라우저를 닫음(cancel/dismiss) → 로그인 화면 유지.
if (result.type !== "success") return;
// result.url 예: myapp://oauth-callback?provider=GOOGLE&code=xxx&state=yyy
const { queryParams } = Linking.parse(result.url);
const code = queryParams?.code;
if (typeof code !== "string" || code === "") return;
const provider =
typeof queryParams?.provider === "string" ? queryParams.provider : "GOOGLE";
const state = typeof queryParams?.state === "string" ? queryParams.state : "";
// 회수한 code로 웹 콜백 URL을 구성해 WebView를 이동시키면,
// 기존 웹 콜백 로직이 WebView 컨텍스트에서 code→token 교환을 수행한다.
const parts = [
`provider=${encodeURIComponent(provider)}`,
`code=${encodeURIComponent(code)}`,
];
if (state) parts.push(`state=${encodeURIComponent(state)}`);
const callbackUrl = `${WEB_ORIGIN}/callback?${parts.join("&")}`;
webViewRef.current?.injectJavaScript(
`window.location.href = ${JSON.stringify(callbackUrl)}; true;`,
);
},
[webViewRef],
);
// 구글 인증 URL이면 가로채(외부 브라우저 오픈) true 반환. 그 외는 false.
return useCallback(
(url: string): boolean => {
if (!url.startsWith(GOOGLE_AUTH_URL_PREFIX)) return false;
void handleGoogleAuth(url);
return true;
},
[handleGoogleAuth],
);
}
WebView 쪽 배선:
// App 컴포넌트 (예제)
const interceptGoogleAuth = useGoogleOAuthRedirect(webViewRef);
const onShouldStartLoad = ({ url }: { url: string }) => {
// 구글 인증 URL이면 가로채고 WebView 로드는 막는다.
if (interceptGoogleAuth(url)) return false;
// 그 외는 기존 처리(다른 소셜/본인인증/외부앱 딥링크 등).
return handleDefaultNavigation({ url });
};
// ...
<WebView
ref={webViewRef}
source={{ uri: WEB_ORIGIN }}
onShouldStartLoadWithRequest={onShouldStartLoad}
/>
앱 딥링크(
myapp://oauth-callback)가 동작하려면 앱의scheme이 등록돼 있어야 한다. Expo라면app.json의"scheme", bare RN이라면 iOSInfo.plist의 URL Types / AndroidAndroidManifest.xml의 intent-filter에 설정한다.
웹 콜백 페이지는 자신이 "외부 relay 브라우저"에서 열렸는지를 판별해, relay면 토큰 교환을 하지 않고 code만 앱 딥링크로 튕긴다.
판별 트릭: 로그인 버튼은 WebView 안에서 눌리므로, 그때 sessionStorage에 심어두는 CSRF state 값이 WebView 컨텍스트에만 존재한다. 외부 브라우저는 별도 컨텍스트라 이 값이 없다. 따라서 "구글 provider인데 state가 없으면 = 외부 relay 브라우저"로 판단한다.
// relayOAuthCodeToApp.ts (예제)
const APP_OAUTH_DEEPLINK = "myapp://oauth-callback";
const OAUTH_STATE_STORAGE_KEY = "oauth_state";
// 외부 relay 브라우저인지 판별.
// oauth_state 는 WebView에서 로그인 버튼 누를 때만 심긴다 → 없으면 외부 브라우저.
export function shouldRelayOAuthToApp({ provider }: { provider: string | null }) {
if (typeof window === "undefined") return false;
if (provider?.toUpperCase() !== "GOOGLE") return false;
return !window.sessionStorage.getItem(OAUTH_STATE_STORAGE_KEY);
}
// 여기서 토큰 교환을 하면 1회용 code가 소진되어 WebView 재진입 시 실패한다.
// 교환하지 말고 code만 그대로 앱 딥링크로 넘긴다.
export function relayOAuthCodeToApp({
provider,
code,
state,
}: {
provider: string;
code: string;
state: string | null;
}) {
const params = new URLSearchParams({ provider, code });
if (state) params.set("state", state);
window.location.href = `${APP_OAUTH_DEEPLINK}?${params.toString()}`;
}
콜백 처리 훅의 최상단(토큰 교환·검증보다 먼저)에 short-circuit을 둔다. 인가 code는 1회용이라, relay 경로에서 교환을 먼저 해버리면 정작 WebView 재진입 시 교환이 실패하기 때문에 순서가 중요하다.
// useOAuthCallback 내부 (예제)
const code = searchParams.get("code");
const provider = searchParams.get("provider");
// 외부 relay 브라우저면 교환하지 말고 딥링크로 넘기고 종료.
if (code && shouldRelayOAuthToApp({ provider })) {
relayOAuthCodeToApp({ provider: provider!, code, state: searchParams.get("state") });
return;
}
// 이하 기존 로직: WebView 컨텍스트에서 code→token 교환 & 세션 저장
이 방식의 장점은 기존 로직을 한 줄도 건드리지 않는다는 것이다. 웹 콜백 앞단에 분기만 얹으면 되고, 구글 콘솔·백엔드·redirect_uri 변경이 전혀 없다.
| 경로 | oauth_state | 동작 |
|---|---|---|
| 앱 WebView 구글 (외부 브라우저 콜백) | 없음 | → 딥링크로 relay |
| 앱 WebView 재진입 (네이티브가 주입) | 있음 | → 정상 교환 ✓ |
| 데스크톱/모바일 웹 구글 | 있음 | → 정상 교환 (영향 없음) ✓ |
| 카카오/네이버/애플 | provider≠GOOGLE | → relay 안 탐, 기존대로 ✓ |
한 가지 실환경에서 주의할 지점이 있다. 웹 relay가 window.location.href = "myapp://..." 로 클라이언트 자바스크립트에서 커스텀 스킴으로 이동하는 방식인데,
ASWebAuthenticationSession): URL 로딩 레벨에서 스킴을 가로채므로 JS 리다이렉트도 안정적으로 감지된다.만약 Android에서 앱으로 자동 복귀가 안 되면, relay 페이지에 "앱으로 돌아가기" 링크(사용자가 직접 탭하는 앵커)를 폴백으로 두면 확실하다. user-gesture 기반 이동은 항상 허용되기 때문이다.
disallowed_useragent)으로 막히는 건 버그가 아니라 구글의 보안 정책이다. embedded WebView 내부 OAuth는 막혀 있고, 앱 코드로 우회할 수 없다(해서도 안 된다).ASWebAuthenticationSession / Android Custom Tabs)에서 인증만 열고, 결과(인가 code)를 딥링크로 앱에 되돌려주는 구조다.openAuthSessionAsync) — 외부 브라우저로 인증 세션을 열고 딥링크로 결과를 받는 API.parse) — 딥링크 URL 파싱.onShouldStartLoadWithRequest — WebView 네비게이션 인터셉트.