miya8060
← back to blog

NOTE · 2026-05-08

X / LINE の内蔵ブラウザで Google OAuth が詰む問題と「動かないボタンを隠す」設計

kokannikki.app に X / Instagram の内蔵ブラウザから飛んでくるユーザーが Google OAuth で 403 disallowed_useragent を踏んで詰むという報告が来た。Google policy はクライアント側で回避不能なので、(a) 動かない provider のボタンを env × policy の両方で gate して隠す、(b) 何が隠れたかを動的 banner で明示する、(c) 外部ブラウザに逃がす導線を platform 別に用意する、の 3 点で対応した。

oauthauthin-app-browseruxkokan-nikkiauth-js

kokannikki.app で「X iOS app の内蔵ブラウザから signin に飛んで Google ログインを押すと真っ白になる」というユーザー報告が来た。エラーは Google の Error 403: disallowed_useragent / アクセスをブロック: Google の『安全なブラウザの使用』に関するポリシーに準拠していません。これは Google が 2021-08 以降 SNS アプリの WebView を一律ブロックしている policy で、サーバー側で UA を見て弾いているため、こちらで JS や header を細工しても通らない。

最初は banner を出して「右上『⋯』→ Safari で ひらく」と案内する PR #117 を出したが、それだと Google ボタン自体は残るのでユーザーは押して 403 を踏む。ボタンを押させない設計に倒したのが PR #119。env (provider 有効化) と policy (UA × provider マトリクス) の両方で gate する純関数 shouldShowOAuthProvider を入れて、隠した provider 名は banner 文言に動的に列挙するようにした。

公開リポジトリではないが、この問題は SNS app からの誘導 traffic がそれなりに来る Web サービスでは普遍的に踏むので、知識として残しておく。

なぜ Google OAuth は in-app browser で動かないか

Google Identity の policy として、host アプリが埋め込んだ WebView (= embedded user agent) からの OAuth flow を disallowed_useragent で 403 にする運用が 2021-08 から始まっている。ブロックの理由は技術的な phishing 耐性で、3 つある。

  1. JavaScript injection: host アプリは WebView に任意の JS を evaluateJavaScript 系 API で inject できる。ユーザーが Google のログイン画面で入力したパスワードを host アプリが読める設計になっている。
  2. URL bar 不在: WebView は URL bar を出さないので、ユーザーが今 accounts.google.com を踏んでいるかを目視確認できない。phishing site の中で Google を装われても気付けない。
  3. Cookie jar 分離: WebView は app ごとに cookie jar が独立しているので、Safari に既にログインしている Google アカウントの session を使えない。WebView 内で再ログインを強いる時点で「アカウントを WebView 経由で扱う」モデルが本来の SSO 設計と合わない。

Apple Sign-In / Microsoft Account も同じ立場の policy を持つ。provider 側のサーバーで弾かれるので、UA を spoofing しても Google は複数のシグナル (refresh token を要求するパターン、IP / Device fingerprint など) で検出して弾く。仮に通せても規約違反なので production には乗せられない。

つまり「Google は in-app browser では絶対に動かない」を所与として UX を設計するしかない。

provider × webview の挙動マトリクス

GoogleLINETwitter (X)
LINE in-app× (403)◯ (LINE 公式仕様)× (cross-app は不可)
X in-app× (403)× (cross-app は不可)◯ (X 公式想定)
Instagram in-app×××
Facebook in-app×××

「同 app の WebView なら provider 自身は通すはず」が LINE / X の前提だが、kokan-nikki 側の実機検証はまだ未実施で judgment call。LINE Login は LINE Developers のドキュメントで「LINE app 内での Login は可能」と明示されているので公式仕様として信頼できる。X (Twitter) については公式ドキュメントが乏しく、X-in-X で OAuth flow が production で完走するかは実機確認が必要。

アプリ内ブラウザで「動く」認証手段は何か

Google OAuth が落ちるなら、何で代替するか。kokan-nikki は Auth.js v5 を使っているので、有効な選択肢は 4 つある。

a. Email magic link (実質的に唯一の汎用解)

Auth.js v5 + Resend の magic link 経路は、同じ webview 内で「メールアドレス入力 → メール本文を開く → リンクをタップ」を完遂すれば cookie jar が分離していても session が成立する。WebView は app ごとに cookie が独立しているので、別アプリ (Mail.app など) でリンクを開くと session は WebView に戻ってこない点だけ注意 (これは UX で「同じアプリ内で開いてください」と案内する)。

kokannikki.app の signin ページは email 入力フォームを最上部に置いて、OAuth ボタンをその下に並べている。in-app browser を検出した場合は OAuth ボタンを丸ごと隠せば、ユーザーは自然に email form に流れる。

b. Passkeys / WebAuthn

理論上は WebView でも動くが、X / LINE / FB の WebView は WebAuthn を完全実装していないことが多い。加えて初回登録は実 device の biometric / Bluetooth security key 経由で外部ブラウザ必須なので、結局「最初の 1 回は外部ブラウザ」問題が残る。子供向け MVP のターゲット (kokan-nikki は小学生も使う想定) には複雑すぎて却下。

c. Credentials provider (パスワード認証)

WebView でも textarea に入力できるので技術的には動く。ただし passkey と違って phishing 耐性も無く、kokan-nikki の MVP コンセプト (パスワード覚えなくていい) と矛盾する。新 provider 追加 + UI + reset 経路 + 強度 validation で半日 〜 1 日仕事になる割に、解決する問題は in-app browser ユーザーだけ。コストが見合わない。

d. Custom Tabs / SFSafariViewController (out of our control)

これは host app (X, LINE) 側が in-app browser 実装に使う SDK の話で、サービス側からは制御できない。X が WebView 実装を Custom Tabs に切り替えれば自然に解決する (Custom Tabs は Chrome の cookie jar を共有するので Google policy をクリアする) が、現状はそうなっていない。「待つ」以外できることは無いので戦略外。

結論として a. magic link 一本に倒すのが現実解。kokan-nikki は元々 magic link が primary path で OAuth は補助なので、in-app browser ユーザーには OAuth ボタンを隠すだけで済む。

UX 設計: 動かない OAuth は隠す + banner で明示する

「banner で『外部ブラウザで開いて』と案内するだけだと、Google ボタンが残っているのでユーザーが押して 403 で詰む」という誤導を防ぐため、provider 側で動かないと判明しているボタンは出さない方針を採用した。

設計ルール

function shouldShowOAuthProvider(
  provider: "google" | "line" | "twitter",
  detection: InAppBrowserDetection | null,
): boolean {
  if (!detection) return true; // 通常ブラウザ → 全表示
  if (provider === "google") return false; // Google は SNS WebView 全般で 403
  return detection.browser === provider; // LINE-in-LINE / X-in-X のみ残す
}

「同 app provider のみ残す」は judgment call で、3 通りの選択肢から選んだ。

  • 全 OAuth 隠す: 最もシンプルで安全だが、LINE-in-LINE で LINE ボタンが消えるのは regression
  • Google だけ隠す: 実装最小だが、LINE webview で X / Instagram webview で LINE が残ってしまい、ユーザーが押したら詰む
  • 同 app provider のみ残す (採用): 理想形。LINE-in-LINE / X-in-X の動作は実機検証が必要 (TODO)
  • disable + tooltip: 「押せないんだ」と理解してもらえるが、grey-out 用の CSS と tooltip 文言が増えて重い

採用した 3 つ目の方式は、「LINE-in-LINE で LINE Login は実際に動く前提」を信じる必要がある。LINE Developers の公式仕様で動く想定として書かれているので、信頼の置きどころとしては妥当。X 側は公式仕様が薄いので、production 実機で OAuth 完走を確認できなければ rule を「X-in-X でも X ボタン隠す」に厳しく倒す follow-up が必要 (現状未確認)。

Banner の動的化

「Google さいんいんが できないよ」と固定文言にすると、X webview では LINE も隠している事実を伝えられない。env 有効 × shouldShow false の provider のラベル配列を計算して banner に渡し、「Google や LINE さいんいんが つかえないよ」のように動的に列挙する。

const blockedProviderLabels: string[] = [];
if (oauthProviders.google && !showGoogle) blockedProviderLabels.push("Google");
if (oauthProviders.line && !showLine) blockedProviderLabels.push("LINE");
if (oauthProviders.twitter && !showTwitter) blockedProviderLabels.push("X");

// banner title:
// `⚠ {appLabel} の中の ブラウザでは ${labels.join(" や ")} さいんいんが つかえないよ`

env 側 (= 管理者がそもそもその provider を有効化していない) で隠れているものは「動かない」のではなく「最初から無い」ので、blocked 列挙には載せない。env 有効 × policy 不可の組み合わせだけが「あったはずなのに今は隠してある」状態に該当する。

外部ブラウザへの逃がし方 (platform 別)

「動かないボタンを隠す」だけでは不親切なので、外部ブラウザに逃がす導線も用意する。これが platform 別に挙動が違って厄介。

webview / platform外部ブラウザ起動方法確実性
LINE (iOS / Android)URL に ?openExternalBrowser=1 を付与してリンクを踏ませるLINE 公式仕様。確実に動く
Android (LINE 以外)intent://kokannikki.app/auth/signin#Intent;scheme=https;package=com.android.chrome;end で Chrome 直接起動動く
iOS (LINE 以外: X / Instagram / FB)確実な scheme jump 無しx-safari-https:// は非公式 + iOS バージョン依存。UI 側で「右上『⋯』→ Safari で ひらく」の手動誘導文に倒すUI 文言頼み

iOS の SNS app の「⋯」メニュー名は app と version によって違う。X は最近 UI 変更が多いので、「Safari で ひらく」固定文言が常に正しい保証はない (実機確認推奨)。Instagram は「⋯」位置が右下のときと右上のときがあるなど、生 SNS UI の追従コストがそれなり。

検出ロジック (UA signature)

function matchBrowser(ua: string): InAppBrowserKind | null {
  if (/\bLine\//.test(ua)) return "line"; // "Line/14.5.0"
  if (/\bTwitter\b|TwitterAndroid/.test(ua)) return "twitter"; // "Twitter for iPhone/10.55"
  if (/\bInstagram\b/.test(ua)) return "instagram"; // "Instagram 308.0.0..."
  if (/\bFBAN\b|\bFB_IAB\b|\bFBAV\b/.test(ua)) return "facebook"; // [FBAN/FBIOS;...]
  return null;
}

完璧な検出ではない (UA は spoofing できるし、新興 SNS app はこのリストに無い) が、主要 SNS アプリは識別可能なシグネチャを残しているので実用カバレッジはある。新規 in-app browser を追加するときは「公式仕様あり (LINE 型)」「Android (intent://)」「iOS (手動誘導のみ)」の 3 ティアで判断する。

教訓

  • Provider policy はクライアント側で回避不能: Google / Apple / Microsoft の OAuth は WebView 拒否を policy としているので、UA spoofing 等で迂回せず magic link / passkey に倒すのが唯一筋。
  • UX は「動かないボタンを残さない」が原則: banner で警告するだけだと斜め読みするユーザーが詰む。enable 状態 (env) と動作可能性 (UA × policy) の両方で gate するのが安全。
  • LINE は SNS の中で例外: 公式仕様で ?openExternalBrowser=1 付与だけで外部ブラウザに自動切替するし、LINE Login は LINE webview 内で動く前提が公式に明示されている。他 SNS と同列に扱わない。
  • iOS の非 LINE 内蔵ブラウザは UI 文言で逃がすしかない: x-safari-https:// などの scheme jump は非公式かつ iOS バージョン依存で本番投入リスクが高い。「右上『⋯』→ Safari で ひらく」の手動誘導文に倒す。
  • 検証は実機が必要: dev / preview の curl 検証では UA 偽装で UI 側の出し分けは確認できるが、provider 側で実際に OAuth が完走するかは production 実機でしか分からない (LINE-in-LINE で LINE OAuth、X-in-X で Twitter OAuth)。

なぜここで効くか

ユーザー獲得チャネルの大半が SNS の Web サービスでは、流入の 3 〜 5 割が in-app browser になる。OAuth ボタンを並べるだけの signin ページは「PC ブラウザ前提」の設計で、SNS 流入のユーザーをサイレントに失う。「動かないと分かっている provider は出さない + 何が隠れたかを言う + 外部ブラウザに逃がす導線を platform 別に出す」の 3 点セットで、流入経路を選ばない signin が初めて成立する。403 ページに飛ばしてユーザー側の責任にしないのが大事。

(Origin: 2026-05-08。kokan-nikki PR #117 で in-app browser 検出 + 外部ブラウザ誘導 banner、PR #119 で動かない OAuth ボタンを隠す + banner 動的化を投入。)