# Android OAuth in WebView: wv, X-Requested-With and Custom Tabs

> Source: <https://dev.to/ryoitabashi/android-oauth-in-webview-wv-x-requested-with-and-custom-tabs-2o15>
> Published: 2026-11-14 03:03:44+00:00

Image: AI-generated illustration

Why does Google sign-in fail inside an Android app's embedded browser while other OAuth providers still complete there? Google's policy bars a developer from directing a Google OAuth 2.0 authorization request "to an embedded user-agent under the developer's control" [1]. Shelly now sends Google OAuth URLs to a real Chrome process through Custom Tabs and leaves the in-app path for Anthropic and GitHub unchanged. The sections below cover three questions: where the block happens and what signals trigger it, how the bridge routes URLs out of the WebView, and which versions, defaults and limits apply.

Google's definition of an embedded user-agent includes software libraries that let a developer insert arbitrary scripts, change the default routing of a request to the Google OAuth server, or access session cookies. Every browsing environment must also let the user verify the current connection to the Google OAuth server, including the requested URI and connection security information [1]. RFC 8252 says native apps "MUST use an external user-agent" for authorization requests. Under the same RFC, authorization endpoints may detect and block embedded user-agents because the hosting application can access authentication credentials [2].

Sites can detect WebView through the wv token unless an application overrides the User-Agent string [3]. In the earlier behavior, WebView added X-Requested-With to every request with the application APK name as its value. The announced change would remove X-Requested-With from general traffic while allowing explicit developer opt-in and a Deprecation Origin Trial for dependent services [4]. Shelly observed Google OAuth refusing to render its sign-in UI through X-Requested-With detection on 2026-05-08.

In Shelly, the problem limited Gemini login, which depends on Google OAuth. Commit 1c367c474 claimed that Codex / Claude / Gemini OAuth could complete inside the app without an external browser. The 2026-05-08 evening handoff instead identified Gemini login as the only remaining Phase 1 gap and said Phase 1.2 unblocks it.

Commit 1c367c474 shipped an in-app OAuth bridge through an xdg-open shim and an RN file queue. Its native xdg-open binary, shipped on 2026-05-08, is built from `jni/shelly-xdg-open.c` into `libshelly_xdg_open.so`. That binary is a direct execve target in jniLibs / libDir, which avoids Android's binfmt_script audit on app_data_file scripts, an audit that Samsung Knox sepolicy denies. HomeInitializer.kt symlinks `$HOME/bin/xdg-open` to the binary and exports `BROWSER`.

URLs pass through `~/.shelly-deep-link-queue`, which `app/_layout.tsx` drains every 250ms. Commit 22e935084 (2026-05-08, PR #50) added the Phase 1.2 Stage 1 foundation on the RN side. Its design keeps URL ownership with the CLI, so PKCE, token exchange and the credential write all run on the CLI's loopback callback, following RFC 8252.

A safety net followed on 2026-07-17 in commit 6bc581864. Before dispatch, `app/_layout.tsx` now upgrades any queue line still routed "in-app" whose URL host is accounts.google.com to "external-browser". This protects against any future or third-party emitter that forgets to set authMode explicitly. Detection of accounts.google.com already existed in shelly-xdg-open.c before this commit.

| Aspect | Before: Phase 1 queue | After: Phase 1.2 | 
|---|---|---|
| Queue line format | Plain-URL lines only | JSON-line file queue with external-browser dispatch | 
| Google OAuth URL target | Always the in-app Browser Pane | A real Chrome process via Custom Tabs (expo-web-browser) | 
| Anthropic / GitHub OAuth | Always the in-app Browser Pane | Existing in-app path left untouched | 

The official platform facts come from Google and Chrome pages. Starting in Android 17, the default User-Agent string in Android WebView is reduced. In the reduced string, OS, CPU and Build information become the static "Linux; Android 10; K" string. Minor/build/patch version information is also reduced to "0.0.0" [3].

Three items come only from Shelly's own records: the Knox denial of script execution, the 250ms queue drain, and the description of X-Requested-With as unconditional. The claim that Chromium injects X-Requested-With unconditionally comes from Shelly's 2026-07-17 commit.

**Full external browser (RFC 8252).** This approach opens the authorization request outside the app and returns the response through a redirect URI. The constraint that remains is PKCE, which public native clients still have to implement [2].

**Auth Tab.** From Chrome 137, Auth Tab can directly replace existing Custom Tabs authentication integrations. Its APIs ship in an alpha build, and the dependency example uses `androidx.browser:browser:1.9.0`. Data arrives through a callback instead of the intent that standard Custom Tabs use to receive browser data. The AndroidManifest.xml intent filters that authentication previously required are no longer needed. The launch call from the guide looks like this [5]:

```
AuthTabIntent authTabIntent = new AuthTabIntent.Builder().build();
authTabIntent.launch(mLauncher, Uri.parse("https://www.example.com/auth"), "mycustomscheme");
```

The redirect form matters before adopting it. On completion, the tab calls back to the application with a result using https or custom schemas. The launch methods accept one of two sets of parameters: a custom `redirectScheme`, or a separate `redirectHost` and `redirectPath` for https redirects. When using https, Digital Asset Link verification is required [5].

The trade-off is a tab that removes most of the customization options available in a standard Custom Tab. For users whose devices don't support Auth Tab, fallback to Custom Tabs is automatic. Apps must handle data that can arrive either at the ActivityResultCallback or at the Activity intent. If AuthTabIntent launches the fallback and the current browser does not support Auth Tab, the app receives `Activity.RESULT_CANCELED` when the Custom Tab closes [5].

`setUserAgentString()` do not send the reduced User-Agent string. An app that appends a suffix to `getUserAgentString()` still sends the default portion in reduced form [3].`CustomTabsClient#isAuthTabSupported()` [5].
AI disclosure: this post was researched, written and reviewed by AI tools.

Publishing: a post that passes automated checks may be published automatically, before the author reads it.
