앱 안 웹뷰에서 "구글로 로그인"을 눌렀더니 로그인 화면 대신 이 문구가 뜹니다.
403. That's an error.
Error: disallowed_useragent
You can't sign in from this screen because
this app doesn't comply with Google's secure browsers policy.
모바일 크롬에서 같은 페이지를 열면 잘 됩니다. 앱 안에서만 막힙니다. 카카오·네이버 로그인은 되는데 구글만 안 되는 것도 흔한 조합입니다.
원인 — 구글이 내장 웹뷰의 로그인을 정책으로 막았다
버그가 아니라 의도된 차단입니다. 구글은 2021년 9월 말부터 앱에 내장된 웹뷰(embedded WebView)에서의 OAuth 인증을 허용하지 않습니다. 이유는 보안입니다.
웹뷰는 앱이 그 안을 들여다볼 수 있습니다. 자바스크립트를 주입해 입력값을 읽거나, 화면을 캡처하거나, 쿠키를 꺼낼 수 있습니다. 즉 악의적인 앱이 진짜 구글 로그인 화면을 띄워놓고 아이디와 비밀번호를 그대로 가져갈 수 있다는 뜻입니다. 사용자는 주소창이 없어서 진짜인지 확인할 방법도 없습니다.
| 내장 WebView | Custom Tabs · 외부 브라우저 | |
|---|---|---|
| 앱이 입력값을 읽을 수 있나 | 가능 | 불가 |
| 주소창(도메인 확인) | 없음 | 있음 |
| 브라우저에 저장된 로그인 세션 | 공유 안 됨 | 공유됨 |
| 구글 OAuth | 차단 | 허용 |
먼저 — 하면 안 되는 우회 두 가지
2) 사용자에게 "크롬을 기본 브라우저로 설정하세요"라고 안내하기. 이것은 사용자 쪽 임시 조치이지 앱의 해결책이 아닙니다. 앱을 쓰는 사람 전원에게 설정을 바꾸라고 요구할 수는 없습니다.
정답은 하나입니다. 로그인 구간만 웹뷰 밖으로 꺼내는 것입니다.
해결 — 로그인만 Custom Tabs로 연다
Chrome Custom Tabs는 앱 안에 떠 있는 진짜 브라우저입니다. 앱은 그 안을 볼 수 없고, 사용자는 주소창으로 도메인을 확인할 수 있으며, 크롬에 로그인돼 있으면 계정 선택만으로 끝납니다. 구글이 요구하는 조건을 모두 만족합니다.
1단계 — 의존성
// build.gradle.kts
implementation("androidx.browser:browser:1.8.0")
2단계 — 로그인 URL만 가로채서 Custom Tabs로 넘긴다
웹뷰는 그대로 두고, 구글 인증 주소로 이동하려는 순간에만 끼어들면 됩니다.
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView, request: WebResourceRequest
): Boolean {
val url = request.url.toString()
if (isOAuthUrl(url)) {
openInCustomTab(view.context, url)
return true // 웹뷰는 열지 않는다
}
return false
}
private fun isOAuthUrl(url: String): Boolean =
url.startsWith("https://accounts.google.com/") ||
url.startsWith("https://appleid.apple.com/auth/authorize")
}
private fun openInCustomTab(context: Context, url: String) {
CustomTabsIntent.Builder()
.setShowTitle(true)
.build()
.launchUrl(context, Uri.parse(url))
}
3단계 — 로그인 끝나고 앱으로 돌아오게 만든다
여기서 대부분 막힙니다. Custom Tabs로 로그인은 됐는데 브라우저에 머물러 있고 앱으로 안 돌아옵니다. 인증이 끝난 뒤 서버가 보내는 리다이렉트 주소를 앱이 받을 수 있는 주소로 만들어야 합니다.
<!-- AndroidManifest.xml -->
<activity
android:name=".AuthCallbackActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- 서버가 최종적으로 리다이렉트할 주소 -->
<data android:scheme="myapp" android:host="auth" />
</intent-filter>
</activity>
class AuthCallbackActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// myapp://auth?token=... 형태로 돌아온다
val token = intent?.data?.getQueryParameter("token")
if (token != null) {
// 세션 저장 후 원래 화면으로 복귀
SessionStore.save(token)
startActivity(Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
})
}
finish()
}
}
myapp://를 직접 넣을 수는 없습니다. 웹 클라이언트는 https 주소만 받습니다. 그래서 흐름은 구글 → 우리 서버(https 콜백) → 앱 스킴 두 단계가 됩니다. 서버가 인증을 마친 뒤 myapp://auth?token=...으로 한 번 더 보내주는 구조입니다.
4단계 — 돌아온 세션을 웹뷰가 알게 한다
Custom Tabs와 웹뷰는 쿠키 저장소가 다릅니다. 브라우저에서 로그인했다고 웹뷰가 로그인 상태가 되지는 않습니다. 그래서 돌려받은 토큰을 웹뷰 쪽에 심어줘야 합니다.
// 토큰을 쿼리로 넘겨 서버가 웹뷰 세션을 만들게 하거나
webView.loadUrl("https://example.com/app/enter?token=$token")
// 서버가 쿠키 기반이라면 직접 주입
CookieManager.getInstance().apply {
setAcceptCookie(true)
setCookie("https://example.com", "session=$token; Path=/; Secure; HttpOnly")
flush() // ★ 빼먹으면 앱 재시작 시 로그인이 풀린다
}
다른 선택지 — 아예 네이티브 로그인으로
웹뷰 껍데기 앱이 아니라 네이티브 화면이 있는 앱이라면, 웹 OAuth 대신 안드로이드 네이티브 로그인을 쓰는 편이 사용자 경험이 훨씬 낫습니다. 계정 선택 시트가 바로 뜨고 브라우저를 거치지 않습니다.
| 방식 | 적합한 경우 | 주의 |
|---|---|---|
| Custom Tabs + 웹 OAuth | 웹 화면이 주인공인 하이브리드 앱 | 세션을 웹뷰로 넘기는 처리가 필요 |
| 네이티브 구글 로그인 | 네이티브 화면 중심 앱 | SHA-1 지문 등록 필수. Play 앱 서명을 쓰면 업로드 키가 아니라 앱 서명 키의 지문이어야 한다 |
10(개발자 오류)입니다. 원인은 대부분 지문 등록 누락이거나 업로드 키 지문만 넣은 것입니다.
점검 순서
| 확인 | 내용 |
|---|---|
| 1 | User-Agent를 조작하는 코드가 남아 있지 않은가 — 있으면 먼저 제거 |
| 2 | 가로채는 URL 조건이 인증 도메인만으로 좁혀져 있는가 |
| 3 | OAuth 콘솔에 등록한 리다이렉트 URI와 서버가 실제로 보내는 주소가 한 글자까지 같은가 |
| 4 | 앱 스킴 intent-filter에 BROWSABLE 카테고리가 있는가 (없으면 브라우저가 앱을 못 부른다) |
| 5 | 콜백 액티비티가 singleTask인가 — 아니면 화면이 중복으로 쌓인다 |
| 6 | 쿠키 주입 후 flush()를 호출했는가 |
| 7 | 크롬이 없는 기기에서도 동작하는가 (Custom Tabs 미지원 시 일반 브라우저로 폴백) |
자주 묻는 질문
Q. User-Agent를 바꿨더니 되던데요?
지금은 통과할 수 있습니다. 문제는 그것이 정책 위반이라 언제든 다시 막힌다는 점입니다. 그때는 이미 출시된 앱의 로그인이 통째로 죽고, 스토어 업데이트가 반영될 때까지 사용자는 들어오지 못합니다. 실제로 이 방식으로 버티다 한 번에 무너지는 사례가 반복돼 왔습니다.
Q. 카카오·네이버 로그인은 웹뷰에서 잘 되는데요?
각 서비스의 정책이 다릅니다. 다만 국내 서비스들도 자체 SDK나 앱 전환 방식을 권장하는 방향으로 가고 있습니다. 구글만 예외적으로 엄격한 것이지, 웹뷰 안에서 남의 계정 로그인을 처리하는 구조 자체가 권장되지 않습니다.
Q. Custom Tabs를 열었더니 브라우저 앱으로 완전히 튀어나갑니다.
CustomTabsIntent가 아니라 일반 ACTION_VIEW 인텐트로 열고 있을 가능성이 큽니다. 또는 기기에 Custom Tabs를 지원하는 브라우저가 없어 폴백된 경우입니다. 후자는 정상 동작이며, 앱 복귀는 앱 스킴 콜백이 처리합니다.
Q. 로그인은 되는데 앱으로 안 돌아옵니다.
3단계의 앱 스킴 설정 문제입니다. BROWSABLE 카테고리 누락, 스킴 오타, 서버가 보내는 최종 리다이렉트 주소 불일치 중 하나입니다. 브라우저 주소창에 myapp://auth를 직접 쳐서 앱이 뜨는지부터 확인하면 빠릅니다.
Q. 앱을 껐다 켜면 로그인이 풀립니다.
쿠키를 주입한 뒤 CookieManager.flush()를 호출하지 않아서입니다. 웹뷰 쿠키는 메모리에 있다가 flush 시점에 디스크로 내려갑니다.
정리
disallowed_useragent는 고칠 수 있는 오류가 아니라 지켜야 할 규칙입니다. 앱이 들여다볼 수 있는 화면에서는 남의 계정 로그인을 받지 말라는 것이고, 그 요구는 앞으로 더 강해질 방향이지 느슨해질 방향이 아닙니다.
따라서 로그인 구간만 Custom Tabs로 꺼내고, 인증이 끝나면 앱 스킴으로 돌아와 세션을 웹뷰에 심는 것이 정공법입니다. 작업량은 반나절이면 충분하고, User-Agent를 바꾸며 버티는 방식보다 훨씬 오래갑니다.
