Skip to main content

Command Palette

Search for a command to run...

React Google Login (Native Sign-In)

React Google login done natively: open a secure browser with the Despia OAuth bridge, handle the callback, and set the session on iOS and Android.

Updated
7 min readView as Markdown
React Google Login (Native Sign-In)

Your React app ships to iOS and Android through Despia, but a Google sign-in button that works in the browser fails inside the native shell. Google blocks its OAuth screen in an embedded WebView, and the App Store expects a real browser session. Despia gives you the native oauth:// bridge, which opens the secure system browser, ASWebAuthenticationSession on iOS and Chrome Custom Tabs on Android, and returns the tokens to your React app. One flow covers both platforms, with a normal redirect on web.

Why the web Google button fails in a mobile app

Google refuses its consent screen inside an embedded WebView, the disallowed_useragent error, because an embedded view could read the user's credentials. Apple's guidelines say the same: OAuth has to happen in a browser the app cannot inspect. So the plain web button either errors or is rejected at review. The fix is to leave the WebView for the sign-in. Despia's oauth:// bridge opens the system browser, the user authenticates, and Despia closes it and returns the tokens. Unlike Apple Sign In, Google uses the same bridge on iOS and Android, so there is no platform branch in your code.

How the flow works

  1. The user taps Sign in with Google.

  2. In Despia, your backend builds a Google OAuth URL that redirects to a small file, native-callback.html, and returns it.

  3. despia('oauth://?url=...') opens that URL in the secure browser.

  4. The user signs in. Tokens arrive at native-callback.html.

  5. That file fires myapp://oauth/auth?access_token=.... The oauth/ prefix tells Despia to close the browser and route the WebView to /auth.

  6. Your /auth route reads the token and creates the session.

On web the button runs your provider's normal redirect and lands on the same /auth.

Wire the sign-in button

Before any code, register your deeplink scheme in the Despia dashboard under Publish > Deeplink, that is the value myapp stands for below. Then install the SDK with npm install despia-native and branch in the click handler:

import despia from 'despia-native'

const isDespia = typeof navigator !== 'undefined' &&
  navigator.userAgent.toLowerCase().includes('despia')

async function signInWithGoogle() {
  if (isDespia) {
    // get the OAuth URL from your backend and open it in the secure browser
    const { url } = await fetch('/api/auth/google-url', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ deeplink_scheme: 'myapp' }), // Despia > Publish > Deeplink
    }).then(r => r.json())
    despia(`oauth://?url=${encodeURIComponent(url)}`)
  } else {
    // web: normal redirect, supabase-js installs the session itself on return
    await supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: location.origin + '/auth' } })
  }
}

Replace myapp with your scheme from Despia > Publish > Deeplink, and swap the web call for your provider if you are not on Supabase.

Generate the OAuth URL on the backend

Your route builds the URL with native-callback.html as the redirect. On Supabase the implicit redirect returns the tokens in the URL hash:

// app/api/auth/google-url/route.js
export async function POST(req) {
  const { deeplink_scheme } = await req.json()
  const redirectUrl = `https://yourapp.com/native-callback.html?deeplink_scheme=${encodeURIComponent(deeplink_scheme)}`

  const url = `${process.env.SUPABASE_URL}/auth/v1/authorize?` + new URLSearchParams({
    provider: 'google',
    redirect_to: redirectUrl,
    scopes: 'openid email profile', // optional, Supabase requests these by default
    flow_type: 'implicit', // implicit is the default without a code_challenge, kept to document intent
  })

  return Response.json({ url })
}

Why implicit in this example? Some React apps, especially Supabase-style client-session apps, need the OAuth result to return directly to the browser context that will call setSession(). In that setup, the secure browser receives the hash tokens, native-callback.html forwards them through Despia's oauth:// bridge, and /auth installs the session in the app. The deeper reason the handoff exists: the OAuth screen runs in a separate secure-browser context, ASWebAuthenticationSession on iOS and Chrome Custom Tabs on Android, whose cookies and storage cannot cross into the WebView by design. A PKCE verifier stored where the flow starts can never meet the code where the session must be installed, so passing the tokens through the deeplink is the bridge between the two worlds. And because the hash carries Supabase's own access and refresh token pair, not a raw Google grant, setSession() yields a full session that supabase-js keeps refreshing, users are not signed out when the access token expires.

If you own the backend, or your provider supports a clean PKCE exchange in the app, prefer authorization-code with PKCE instead of this client-token handoff. Supabase supports that with exchangeCodeForSession(), and custom backends should generally use code plus PKCE rather than passing long-lived tokens through the URL. Either way the Despia side is identical: the same oauth:// open, the same callback file, the same oauth/ deeplink home.

Add public/native-callback.html

This small page runs inside the secure browser. It reads the tokens from the URL hash and fires the deeplink that closes the browser and passes them to your app. Keep it as a plain HTML file in public/, not a React component: React Router can strip the hash on a route change before you read it, and a static file bypasses the router entirely.

<!-- public/native-callback.html -->
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8" /><title>Completing sign in...</title></head>
<body>
<p>Completing sign in...</p>
<script>
  (function () {
    var params = new URLSearchParams(location.search)
    var scheme = params.get('deeplink_scheme')
    if (!scheme) return

    var hash = new URLSearchParams(location.hash.slice(1))
    var accessToken = hash.get('access_token')
    var refreshToken = hash.get('refresh_token') || ''
    var error = hash.get('error') || params.get('error')

    // oauth/ tells Despia to close the browser and navigate the WebView to /auth
    location.href = accessToken
      ? scheme + '://oauth/auth?access_token=' + encodeURIComponent(accessToken) + '&refresh_token=' + encodeURIComponent(refreshToken)
      : scheme + '://oauth/auth?error=' + encodeURIComponent(error || 'no_access_token')
  })()
</script>
</body>
</html>

The oauth/ prefix is not optional. myapp://oauth/auth closes the session and opens /auth. myapp://auth without it does nothing, and the user is stuck in the browser.

Read the token on /auth

Both flows end here: the native deeplink puts the token in the query string, the web redirect puts it in the hash. Read both, then hand off to your provider's session call.

import { useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'

function Auth() {
  const [searchParams] = useSearchParams()
  const navigate = useNavigate()

  useEffect(() => {
    const check = () => {
      const q = new URLSearchParams(window.location.search)
      const hash = new URLSearchParams(window.location.hash.slice(1))
      const accessToken = q.get('access_token') || hash.get('access_token')
      const refreshToken = q.get('refresh_token') || hash.get('refresh_token') || ''
      if (!accessToken) return

      // setSession is your provider's call, e.g. supabase.auth.setSession()
      setSession({ access_token: accessToken, refresh_token: refreshToken }).then(() => navigate('/'))
    }
    check()
    // searchParams in deps catches query changes; hashchange catches hash-only ones
    window.addEventListener('hashchange', check)
    return () => window.removeEventListener('hashchange', check)
  }, [searchParams, navigate])

  return <p>Signing you in...</p>
}

Two triggers cover the ways the URL can change while /auth is already mounted: searchParams in the dependency array catches query changes, and the hashchange listener catches hash-only changes, which do not update searchParams. Miss either and a deeplink into an open page runs once with empty params and never again.

Set up Google in the Cloud Console

  1. In console.cloud.google.com, open APIs and Services, then Credentials, and create an OAuth 2.0 Client ID of type Web application.

  2. Add your app domain under Authorized JavaScript origins.

  3. Under Authorized redirect URIs, add your provider's callback. For Supabase that is https://YOUR-PROJECT.supabase.co/auth/v1/callback; for a custom backend, https://yourapp.com/api/auth/google-callback.

  4. Copy the Client ID and Client Secret into your provider's Google settings.

  5. Set your deeplink scheme from Despia > Publish > Deeplink and replace myapp in the code.

The four things that cause almost every failure

The oauth/ prefix must be in the deeplink, or Despia never closes the browser. native-callback.html must be a real file in public/, not a React route, or the hash gets stripped. /auth must read tokens from both the query string and the hash, since native and web deliver them differently. And /auth must re-run its token logic on a URL change, which is exactly why the handler is re-armed on both searchParams changes and hashchange.

Ship it with native sign-in built in

Take your React app to iOS and Android with Google sign-in built for review, from the codebase you already have. Code signing and submission run from the browser, no Mac and no CLI.

See the setup docs at setup.despia.com