Use this file to discover all available pages before exploring further.
Sign In with Apple using the Apple JS SDK in three flavors. iOS uses the SDK’s native popup mode to surface the Face ID / Apple ID sheet directly inside the WebView. Android cannot trigger that sheet, so the flow goes through Chrome Custom Tabs and a deeplink bridge. Web uses the standard SDK popup.
All native capabilities here come from despia-native. No additional native libraries are needed. The Apple JS SDK script tag must be placed before your app script.
On iOS, the SDK has a special hook into the system that lets it summon the native Apple ID sheet from inside a WebView, so no external browser is involved. On Android no such hook exists, so the flow opens Chrome Custom Tabs, lets Apple redirect to a small bridge HTML file, and uses a deeplink with the oauth/ prefix to close the tab and pass the token back to the WebView.
Use the canonical user agent check. All three constants are derived from the same toLowerCase() call. Gate every native call behind the relevant constant so the flow degrades cleanly in a browser and on the wrong platform.
Initialize the SDK and call signIn() with usePopup: true. The id_token arrives directly in the JS callback, no redirect, no oauth:// bridge.
Always use usePopup: true and read the id_token from the JS callback. Using a redirect instead causes a blank white screen during the auth flow and will result in App Store rejection.
Known regression in iOS 17.1, usePopup: true showed an HTML form instead of the native Face ID sheet inside WKWebView. Fixed in iOS 17.2 and later. Authentication still worked, just without biometrics.
Generates the Apple OAuth URL with native-callback.html as the redirect. Pass the deeplink scheme through as a query param so the bridge page can read it.
This page runs inside Chrome Custom Tabs. Apple redirects here after auth, the page reads the tokens, then fires a deeplink to close the tab and pass tokens back to the WebView. Users never see the .html because Chrome Custom Tabs hides the URL bar.Use a plain HTML file in public/, not a React component. React Router can strip the #id_token hash fragment on route change, causing tokens to disappear before your callback logic runs.Apple supports two response modes. Pick one:
fragment
form_post
Apple callback POST handler
No, Apple redirects browser directly
Yes, Apple POSTs to your server
Tokens arrive
URL hash #id_token=xxx
POST to your server, then redirect
Security
Lower
Higher
fragment, HTML (Recommended)
form_post, HTML
fragment, React
Apple redirects to native-callback.html with #id_token=xxx&code=xxx in the hash. No backend POST handler needed.
Apple POSTs code, id_token, state, and user (name and email, first login only) to your backend. Your backend validates, creates a session token, then redirects to native-callback.html with that token in query params.
Apple only sends the user’s name on the very first login. Capture and store it in your POST handler immediately.
Backend POST handler:
// POST /api/apple-callback, receives as application/x-www-form-urlencodedexport async function POST(req) { const body = await req.formData() const idToken = body.get('id_token') const state = body.get('state') const userJson = body.get('user') // first login only const deeplinkScheme = new URLSearchParams(state).get('deeplink_scheme') const sessionToken = await validateAndCreateSession(idToken, userJson) return Response.redirect( `https://yourapp.com/native-callback.html` + `?deeplink_scheme=${encodeURIComponent(deeplinkScheme)}` + `&session_token=${encodeURIComponent(sessionToken)}` )}
native-callback.html, reads from query params, not hash:
The oauth/ prefix in the deeplink is required. myapp://oauth/auth closes Chrome Custom Tabs and navigates the WebView to /auth. myapp://auth without it does nothing, the user stays stuck in the tab.
After Despia closes the tab and navigates to /auth?id_token=xxx, your auth page reads the token and creates a session.
If /auth is already mounted when the deeplink arrives, your framework updates the URL without remounting. Token-reading logic that only runs on mount has already fired with empty params and will not run again. The tokens sit in the URL and nothing happens. The fix is framework-specific and covered in the tabs below.
React
Vue
Vanilla JS SPA
HTML
Include searchParams in the useEffect dependency array. Without it the effect fires once on mount and ignores all subsequent URL changes.
Run the handler on load and on popstate. History API-based routers fire popstate when navigating without a full reload.
function handleAuthParams() { var p = new URLSearchParams(window.location.search) var idToken = p.get('id_token') var sessionToken = p.get('session_token') var code = p.get('code') || '' var error = p.get('error') if (error) { console.error(error); return } if (sessionToken) setSessionFromToken(sessionToken).then(function () { window.location.href = '/' }) else if (idToken) exchangeAppleIdToken(idToken, code).then(function () { window.location.href = '/' })}handleAuthParams()window.addEventListener('popstate', handleAuthParams)
In a WebView, navigating to a page that is already loaded may not trigger a full reload. Run the handler on load and on popstate.
<p id="status">Signing you in...</p><script> function handleAuthParams() { var p = new URLSearchParams(window.location.search) var idToken = p.get('id_token') var sessionToken = p.get('session_token') var code = p.get('code') || '' var error = p.get('error') if (!idToken && !sessionToken && !error) return if (error) { document.getElementById('status').textContent = 'Sign in failed: ' + error; return } if (sessionToken) setSessionFromToken(sessionToken).then(function () { window.location.href = '/' }) else if (idToken) exchangeAppleIdToken(idToken, code).then(function () { window.location.href = '/' }) } handleAuthParams() window.addEventListener('popstate', handleAuthParams)</script>
Go to Certificates, Identifiers & Profiles > Identifiers, create an App ID, and enable Sign In with Apple.
2
Create a Services ID
Create a Services ID (e.g. com.yourcompany.yourapp.webauth). This is your clientId.
3
Configure the Services ID
Enable Sign In with Apple, click Configure, and add your domain and return URL. The redirectURI in your code must match the origin of the page running the SDK exactly. https://yourapp.com/ not https://yourapp.com/auth. The trailing slash must match too.
4
Create a private key
Go to Keys, create a key with Sign In with Apple enabled, download the .p8 file, note your Key ID and Team ID.
5
Configure your backend
Custom Backend
No-Code Platform
Generate a signed JWT from your .p8 key to use as client_secret. Use jsonwebtoken (Node.js) or equivalent. Valid for up to 6 months.
Find the Apple provider in your auth dashboard. Enter Team ID, Key ID, Services ID, and .p8 file contents. The platform handles JWT generation.
The client secret JWT expires after 6 months. Set a calendar reminder. Apple Sign In will silently stop working when it expires.
Use this section when the Android OAuth flow is not working as expected. Identify which stage is broken first, then use the debug overlay to confirm what arrived at your /auth page.The flow has four stages. A failure in one looks completely different from a failure in another:
Stage 1 Your backend generates an Apple OAuth URLStage 2 despia('oauth://?url=...') opens Chrome Custom Tabs, user authenticatesStage 3 Apple redirects to native-callback.html which reads the token and fires the deeplinkStage 4 Despia closes Chrome Custom Tabs and navigates WebView to /auth?id_token=xxx
The debug overlay below tells you whether Stage 4 received anything. Work backwards from there.
Add this to your /auth page during development. Remove it before submitting to the App Store or Google Play. Apple reviewers authenticate through your app during review and will see it.
React
HTML
Swap in this standalone component as your /auth route during testing. Route it back to your real Auth component before shipping.
// src/pages/AuthDebug.jsx, swap in as /auth route during testing onlyimport { useEffect, useState } from 'react'import { useSearchParams } from 'react-router-dom'const AuthDebug = () => { const [searchParams] = useSearchParams() const [info, setInfo] = useState('') // searchParams in the dependency array is critical. // without it this effect runs once on mount and never again, // missing tokens that arrive via deeplink on an already-mounted page useEffect(() => { setInfo( 'Full URL:\n' + window.location.href + '\n\n' + 'id_token:\n' + (searchParams.get('id_token') || '(none)') + '\n\n' + 'session_token:\n' + (searchParams.get('session_token') || '(none)') + '\n\n' + 'code:\n' + (searchParams.get('code') || '(none)') + '\n\n' + 'error:\n' + (searchParams.get('error') || '(none)') ) }, [searchParams]) return ( <div style={{ padding: 20, fontFamily: 'monospace', fontSize: 13 }}> <p style={{ marginBottom: 8, fontWeight: 'bold' }}> Auth Debug, remove before shipping </p> <textarea readOnly value={info} style={{ width: '100%', height: 280, fontSize: 12, border: '1px solid #ccc', padding: 10, boxSizing: 'border-box', fontFamily: 'monospace', }} /> <p style={{ marginTop: 10, fontSize: 11, lineHeight: 1.5 }}> Empty? Token did not arrive, check native-callback.html and the deeplink format.<br /> Token present but not signed in? Your auth logic is not reacting to URL changes. </p> </div> )}export default AuthDebug
// Swap in during testing<Route path="/auth" element={<AuthDebug />} />// Restore before shipping<Route path="/auth" element={<Auth />} />
Add this block to your /auth page. It runs on load and on popstate so it updates whether the page reloaded fresh or was already open when the deeplink arrived.
<!-- AUTH DEBUG, remove before shipping --><div id="auth-debug" style=" position:fixed;bottom:0;left:0;right:0; background:#fff;font-family:monospace; font-size:12px;padding:10px;z-index:9999; border-top:1px solid #ccc;"> <div style="font-weight:bold;margin-bottom:6px;"> Auth Debug, remove before shipping </div> <textarea id="auth-debug-out" readonly style=" width:100%;height:120px; border:1px solid #ccc;font-size:11px;font-family:monospace;padding:6px;box-sizing:border-box; resize:none;outline:none;display:block;"></textarea> <div style="font-size:10px;margin-top:6px;line-height:1.5;color:#555;"> Empty? Token did not arrive, check native-callback.html and the deeplink format.<br /> Token present but not signed in? Your auth logic is not reacting to URL changes. </div></div><script> function updateDebug() { var el = document.getElementById('auth-debug-out') if (!el) return var p = new URLSearchParams(window.location.search) el.value = 'Full URL:\n' + window.location.href + '\n\n' + 'id_token:\n' + (p.get('id_token') || '(none)') + '\n\n' + 'session_token:\n' + (p.get('session_token') || '(none)') + '\n\n' + 'code:\n' + (p.get('code') || '(none)') + '\n\n' + 'error:\n' + (p.get('error') || '(none)') } updateDebug() window.addEventListener('popstate', updateDebug)</script><!-- END AUTH DEBUG -->
Chrome Custom Tabs does not open. Log the URL before passing it to despia() and confirm it is a valid HTTPS URL. Depending on your backend it may start with https://appleid.apple.com/auth/authorize (custom backend), or your Supabase, Firebase, or other hosted auth provider’s own OAuth endpoint. The important thing is that it is a full HTTPS URL and not empty or malformed.native-callback.html not reached. The redirect_uri in your OAuth URL must exactly match the return URL registered in the Apple Developer Console including https://, the full domain, the path, and the .html extension. Apple does exact string matching.Hash fragment empty in native-callback.html. Some hosting platforms strip hash fragments from redirects. Log window.location.href at the top of the script to confirm the full URL arrived. If using a React component for the callback, switch to public/native-callback.html. React Router may be stripping the hash.Deeplink does not close Chrome Custom Tabs. The oauth/ segment must be present, myapp://oauth/auth. Without it Despia does not intercept the deeplink and the tab stays open. Find your scheme in Despia > Publish > Deeplink.Tokens arrive but sign-in never completes. Either the backend request failed silently (add error logging), or the auth logic is not running because the page was already mounted. See Handling tokens at /auth above.
The Apple JS SDK with usePopup: true opens the native Face ID / Apple ID sheet directly via a special Apple API. No external browser session is opened so there is nothing to close. The oauth:// bridge and native-callback.html are Android-only.
What does the oauth/ prefix do?
It signals Despia to close Chrome Custom Tabs and navigate the WebView to the path that follows. myapp://oauth/auth closes the tab and opens /auth. Without oauth/ the deeplink is ignored and the user stays in the tab.
Why use native-callback.html instead of a React component?
React Router can strip the #id_token hash fragment when it handles a route change, causing the token to disappear before your code reads it. A plain HTML file in public/ bypasses React Router entirely. The .html extension is never visible since Chrome Custom Tabs hides the URL bar.
The Apple JS SDK is not loading.
The script tag must be placed before your app script. The SDK requires HTTPS, it will not load on http://localhost. Check for Content Security Policy errors in the browser console.
Tokens are in the URL but the user is not signed in.
The /auth page was already open when the deeplink arrived. Your framework updated the URL without reloading, and your token handler already ran with empty params.Fix per framework:
React, add searchParams to your useEffect dependency array
Vue, use watch: { '$route.query': { immediate: true, handler } } instead of mounted()
Vanilla JS / HTML, call your handler on load and add window.addEventListener('popstate', handler)