Skip to main content
Native apps cannot handle OAuth redirects the way browsers do. Despia solves this with two URL protocols that together handle opening a secure browser session, returning the user to your app, and passing tokens back to your WebView, without any native code changes on your part.

Installation

The despia() function is only available after importing despia-native via npm or CDN. Calling it without importing will throw a reference error.
npm install despia-native
import despia from 'despia-native';
When a user taps “Sign in”, OAuth requires opening a provider’s login page, authenticating, then redirecting back with tokens. In a browser this is straightforward. In a native app running a WebView it breaks down:
ProblemWhy it matters
No visible address barUsers cannot verify they are on the real provider’s site
WebView restrictionsGoogle and Apple block OAuth inside embedded WebViews for security
App Store requirementsBoth platforms require OAuth to happen in a trusted browser session
Session isolationTokens obtained in the browser session cannot transfer directly to your WebView
Despia solves this by opening the OAuth flow in the platform’s secure browser APIs: ASWebAuthenticationSession on iOS and Chrome Custom Tabs on Android. Both provide a trusted, isolated browser session the user recognises as secure. Your WebView never handles the redirect.

The two Despia URL protocols

Everything in Despia’s OAuth mechanism comes down to two URL protocols:
ProtocolWhat it does
oauth://Opens a secure browser session with the URL you provide
{scheme}://oauth/Closes the secure browser session and navigates your WebView to the path that follows
// Open a native secure browser session
despia(`oauth://?url=${encodeURIComponent(oauthUrl)}`)

// Close it and return to your app (fires from inside the browser session)
window.location.href = `myapp://oauth/auth?access_token=xxx`
Everything else is standard OAuth. Despia does not modify the protocol, it provides the secure transport.

The full native flow


Key concepts

The callback URL split

In standard web OAuth you have one callback URL. In Despia you need two: /native-callback runs inside the secure browser session, receives the authorization code or tokens from the provider, does the code exchange if needed, then fires the deeplink to close the session. /auth runs in your WebView, receives tokens via URL params from the deeplink, calls setSession(), and completes login.
Secure browser session:
  Provider → /native-callback?code=xxx
           → calls backend to exchange code
           → fires myapp://oauth/auth?access_token=xxx

WebView:
  myapp://oauth/auth?access_token=xxx
  → Despia closes browser, navigates WebView to /auth?access_token=xxx
  → /auth reads token, calls setSession()

The oauth/ prefix requirement

The oauth/ segment in the deeplink is not a path. It is a signal to Despia to close the secure browser session. Everything after oauth/ becomes the path Despia navigates your WebView to.
DeeplinkResult
myapp://oauth/auth?token=xxxBrowser closes, WebView navigates to /auth?token=xxx
myapp://oauth/homeBrowser closes, WebView navigates to /home
myapp://auth?token=xxxBrowser stays open, user is stuck
Once the secure browser session opens, your /native-callback page has no direct access to the Despia context that opened it. The OAuth state parameter is the correct way to carry anything the callback needs, including the deeplink scheme. The provider echoes state back unchanged.
// When generating the OAuth URL
const state = `${crypto.randomUUID()}|myapp`  // uuid|deeplink_scheme

// In native-callback
const state  = new URLSearchParams(window.location.search).get('state')
const scheme = state.includes('|') ? state.split('|')[1] : 'myapp'

window.location.href = scheme + '://oauth/auth?access_token=' + encodeURIComponent(token)
The state parameter also serves as CSRF protection in the standard OAuth sense. Both uses are compatible.

Token handoff

The secure browser and your WebView are isolated. Tokens obtained in the browser cannot be accessed by your app directly. They must be passed via URL:
Browser fires:   myapp://oauth/auth?access_token=xxx&refresh_token=yyy
Despia intercepts, closes browser, navigates WebView to:
                 /auth?access_token=xxx&refresh_token=yyy
Your /auth page reads the params and calls setSession()

Implicit vs authorization code flow

Different providers return tokens differently from the callback. Your /native-callback page needs to handle whichever your provider uses.
FlowHow tokens arriveExamples
ImplicitURL hash #access_token=xxxSupabase Google (legacy), some OIDC providers
Authorization codeQuery param ?code=xxx, then backend exchangeTikTok, GitHub, most modern providers
// In native-callback, handle both
var params      = new URLSearchParams(window.location.search)
var hash        = new URLSearchParams(window.location.hash.substring(1))

var code        = params.get('code')         // authorization code flow
var accessToken = hash.get('access_token')   // implicit flow

if (code) {
    // exchange code via backend, then fire deeplink with tokens
} else if (accessToken) {
    // fire deeplink directly with token from hash
}

Provider quirks

Apple Sign In on iOS

Apple Sign In on iOS is special. The Apple JS SDK with usePopup: true opens the native Face ID / Apple ID sheet directly inside WKWebView without needing the oauth:// bridge. The id_token is returned to your JavaScript callback with no browser session opened or closed. On Android, Apple Sign In still uses the oauth:// bridge. See the Apple Sign In page for full details.

response_mode=form_post

Some providers (Apple on Android, some enterprise IdPs) POST tokens directly to your backend instead of redirecting the browser. Your backend receives the POST, validates, then redirects the browser to /native-callback or directly to the deeplink:
Provider → POST to your backend → backend redirects to myapp://oauth/auth?tokens

response_mode=query is invalid with id_token

If you are requesting an id_token, Apple and some OIDC providers will reject response_mode=query. Use fragment or form_post instead.

native-callback.html vs React component

Recommendation: use public/native-callback.html. React Router can strip the #access_token hash fragment when handling a route change, causing tokens to disappear before your callback logic runs. A plain HTML file in your public/ folder completely bypasses React Router and reads the hash directly from the browser. The .html extension is never visible, the secure browser hides the URL bar during OAuth flows. If you use a React component, use useLayoutEffect (not useEffect) to read the hash before React re-renders, and make sure React Router does not treat the fragment as part of the route.

The already-mounted /auth page problem

When Despia navigates the WebView to /auth?access_token=xxx, if /auth is already the active route, your framework does not remount the component. It updates the URL and re-renders. If your token-reading logic only runs on mount, it already fired with empty params and will not run again. Tokens sit in the URL, the user sees a loading state forever. Fix per framework: React: include searchParams in your useEffect dependency array. Vue: use watch: { '$route.query': { immediate: true, handler } } instead of reading params in mounted(). Vanilla JS / HTML: call your handler on load and add window.addEventListener('popstate', handler). Note that this also affects plain HTML pages inside a WebView. If the page was already loaded when Despia navigates to it, the browser may focus the existing page rather than reloading it. Run the handler on both load and popstate.

Flow comparison by provider

ProviderToken flowTokens arrive viaBackend needed
Google (Supabase)Implicit#access_token in hashNo (Supabase handles exchange)
Google (custom)Authorization code + PKCE?code=xxx, backend exchangesYes
Apple (iOS)JS SDK callbackresponse.authorization.id_tokenYes (validate id_token)
Apple (Android)Implicit or form_post#id_token in hash or POST to backendNo for fragment, yes for form_post
TikTokAuthorization code?code=xxx, backend exchangesYes, always
GitHubAuthorization code?code=xxx, backend exchangesYes

DeeplinkResult
myapp://oauth/auth?access_token=xxxCloses browser, WebView navigates to /auth?access_token=xxx
myapp://oauth/auth?id_token=xxxCloses browser, WebView navigates to /auth?id_token=xxx
myapp://oauth/homeCloses browser, WebView navigates to /home
myapp://oauth/auth?error=access_deniedCloses browser, WebView navigates to /auth?error=access_denied
myapp://auth?access_token=xxxBrowser stays open, user is stuck

Summary

ConceptWhat to remember
despia('oauth://?url=...')Opens ASWebAuth on iOS, Chrome Custom Tabs on Android
{scheme}://oauth/ prefixCloses the browser session, path after becomes WebView destination
/native-callbackRuns in browser: receives code/tokens, fires deeplink
/authRuns in WebView: receives tokens via URL params, calls setSession
state parameterCarry deeplink_scheme plus CSRF token through the OAuth flow
Already-mounted pageAdd searchParams to deps (React), watch $route.query (Vue), use popstate (HTML/vanilla)
usePopup: true (Apple iOS)Skips the browser session entirely, returns id_token directly to JS

Provider guides

Google Auth

Implicit flow via Supabase or PKCE with custom backend

Apple Sign In

Native Face ID on iOS, Chrome Custom Tabs on Android

TikTok Auth

Authorization code flow with edge function

More providers

The same pattern works for GitHub, LinkedIn, and any OAuth 2.0 provider