Web App Apple Login (Native Sign-In)
Web app Apple login done natively: the Apple JS SDK opens the Face ID sheet on iOS, Android rides the OAuth bridge, one handler covers all three.

Whatever your web app is built with, plain HTML and JavaScript included, shipping it to iOS with Google or any social login triggers Guideline 4.8: you must offer an equivalent privacy-focused login next to it, one that limits data collection and supports private email, and Sign in with Apple is usually the simplest compliant choice, unless one of Guideline 4.8's exceptions applies. Apple lists exceptions for proprietary account systems, enterprise and education accounts, and government identity systems. The upside is that Apple is the easy provider. Its JS SDK with usePopup: true can present the native Apple sign-in sheet from the WebView in Despia, so iPhone needs no browser session, no bridge, and no callback file. Android has no equivalent and rides Despia's oauth:// bridge, reusing the exact plumbing from Google login if you have it.
Three platforms, two flows
On iOS, AppleID.auth.signIn() with usePopup: true opens the native sheet in place and resolves with the id_token in your JS callback. On the web, the identical call opens a browser popup. On Android, your backend builds an OAuth URL, despia('oauth://?url=...') opens Chrome Custom Tabs, and the session returns through the oauth/ deeplink like every other bridge flow.
Apple Developer Console setup
Under Certificates, Identifiers and Profiles, open your App ID and enable Sign In with Apple.
Create a Services ID, for example
com.yourcompany.yourapp.webauth. That is theclientIdyour code uses. The Services ID drives web and JS Sign in with Apple, while the App ID capability from step 1 enables the native app side.Configure the Services ID: enable Sign In with Apple, set the Primary App ID, add your domain, and register the Return URLs, comma-delimited,
https://required, no wildcards, exact string match with the trailing slash included. Which URLs depends on your Android variant, and mixing them up is the number one rejection cause, so keep the three different URLs in this setup straight. Apple's registered Return URL is where Apple itself returns. Yourredirect_tois where Supabase forwards afterwards, allowlisted in Supabase, not Apple. The deeplink is how the result re-enters the app, and Apple never sees it.Always register
https://yourapp.com/for the JS SDK popup on iOS and web.Direct Apple variant: also register your callback endpoint,
https://yourapp.com/api/auth/apple-callback, since that is theredirect_uriyour Android URL sends.Supabase-brokered variant: also register Supabase's callback,
https://YOUR-PROJECT.supabase.co/auth/v1/callback, because Apple returns to Supabase, not to you. Yournative-callback.htmlis never registered with Apple in this variant, it only goes in Supabase's redirect allowlist as theredirect_totarget.
The
.p8question splits by variant, so here it is as two explicit branches. Direct Appleid_tokenverification, the popup and the direct Android leg in this guide: no.p8and no client secret, the token is verified against Apple's public keys. Supabase-brokered Apple OAuth: a.p8signing key is required, Supabase uses it to generate the client secret, which expires and must be regenerated at least every six months. Token revocation also needs authenticated Apple calls, so a.p8enters the picture there too.If Supabase is your auth provider, add your Services ID to the Apple provider's authorized client IDs under Authentication, Providers, Apple, so
signInWithIdTokenaccepts tokens issued for it. Theid_tokenmethod needs no client secret. (The client-secret JWT is only for Supabase's server-side OAuth redirect flow, and it expires within six months, another reason to prefer theid_tokenflow here.)
Load the SDK and detect the platform
Apple's script tag goes before your app script, and the SDK only initializes over HTTPS, not plain localhost. The HTTPS rule has one Despia-specific consequence: the Local Server offline mode serves your app over HTTP, so Apple sign-in cannot initialize there. If your app uses it, open the Despia Editor and switch Offline Support from Native to PWA, then add a service worker that caches your assets. That keeps offline support through the service worker while giving Apple's SDK the secure HTTPS origin it requires.
<script src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"></script>
Install the SDK with npm install despia-native, or load it from the CDN, and derive the three constants once:
const ua = navigator.userAgent.toLowerCase()
const isDespia = ua.includes('despia')
const isDespiaIOS = isDespia && (ua.includes('iphone') || ua.includes('ipad'))
const isDespiaAndroid = isDespia && ua.includes('android')
const isWeb = !isDespia
One handler for all three platforms
iOS and web share the SDK popup and hand the id_token to your backend to verify. Android fetches an OAuth URL and opens the bridge. usePopup: true is mandatory: the redirect variant blanks the WebView and fails App Review.
import despia from 'despia-native'
function toBase64Url(bytes) {
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function signInWithApple() {
// iOS + Web: Apple's JS SDK popup (native sheet on iOS, browser popup on web)
if (isDespiaIOS || isWeb) {
// the backend issues the nonce and remembers it, so the token is bound
// to a sign-in the server itself started
const { nonce } = await fetch('/api/auth/apple-nonce', { method: 'POST' }).then(r => r.json())
window.AppleID.auth.init({
clientId: 'com.yourcompany.yourapp.webauth', // Your Services ID
scope: 'name email',
redirectURI: window.location.origin + '/', // Must match Apple config
usePopup: true, // Required inside a WebView
nonce,
})
const response = await window.AppleID.auth.signIn()
const idToken = response.authorization.id_token
// Apple only sends the name on the very first sign-in
const name = response.user?.name
const fullName = name ? `${name.firstName || ''} ${name.lastName || ''}`.trim() : ''
// Send the id_token to your backend, with the nonce it must match
await exchangeAppleToken(idToken, fullName, nonce)
}
// Android: no native sheet exists, continue via Chrome Custom Tabs + deeplink
if (isDespiaAndroid) {
// PKCE-style verifier: another app squatting the custom scheme could grab
// the handoff code first, but without this verifier the code is useless.
// RFC 7636's recommended construction, 32 random bytes as unpadded base64url
const verifierBytes = crypto.getRandomValues(new Uint8Array(32))
const verifier = toBase64Url(verifierBytes) // 43 chars, 256 bits
// WebView sessionStorage: invisible to the Chrome Custom Tab, and that is
// fine, only /auth back in this same WebView ever needs it
sessionStorage.setItem('apple_verifier', verifier)
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
const challenge = toBase64Url(new Uint8Array(digest))
// the deeplink scheme itself stays a server-side constant, only the challenge goes up
const res = await fetch('/api/auth/apple-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code_challenge: challenge }),
})
if (!res.ok) throw new Error('could not start Apple sign-in')
const { url } = await res.json()
despia(`oauth://?url=${encodeURIComponent(url)}`)
// sign-in resumes on /auth when the deeplink lands
}
}
With a generic backend, exchangeAppleToken posts the id_token to your own endpoint, which verifies it against Apple's public keys and returns your session. Verification is non-negotiable, the token arrives from the client, so the endpoint must prove Apple issued it for your app before trusting a single claim. On Node, jose does the whole job, signature against Apple's JWKS, expiry, issuer, and audience:
// backend: verify the Apple id_token before creating any session
import { createRemoteJWKSet, jwtVerify } from 'jose'
const appleJwks = createRemoteJWKSet(new URL('https://appleid.apple.com/auth/keys'))
async function verifyAppleIdToken(idToken) {
const { payload } = await jwtVerify(idToken, appleJwks, {
issuer: 'https://appleid.apple.com',
audience: 'com.yourcompany.yourapp.webauth', // your Services ID, blocks token substitution
algorithms: ['RS256'], // pin the alg, which also keeps the SHA-256 c_hash check consistent
})
return payload // payload.sub is the stable user id, payload.email when granted
}
jwtVerify checks the RS256 signature and exp for you, the options pin iss and aud. The popup's nonce comes from the server, a tiny endpoint issues it and remembers it in the same pending store the Android leg uses:
// POST /api/auth/apple-nonce, issues a single-use nonce bound to THIS browser
// session, needs cookie-parser (or your framework's cookie handling)
app.post('/api/auth/apple-nonce', (req, res) => {
const nonce = crypto.randomUUID()
const txnId = crypto.randomUUID()
pending.set(nonce, { txnId, createdAt: Date.now() })
res.cookie('apple_txn', txnId, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: TTL })
res.set('Cache-Control', 'no-store') // never cache credentials material
res.json({ nonce })
})
With the helper in place, here is the complete endpoint: verify, consume the server-issued nonce, find or create the user, and mint your own short-lived session. Nothing is trusted before verification, and lookups go by sub, never email, since Apple emails can be private relay addresses that users can rotate or withhold.
// POST /api/auth/apple, the endpoint exchangeAppleToken calls
app.post('/api/auth/apple', express.json(), async (req, res) => {
const { id_token, full_name, nonce } = req.body
let claims
try {
claims = await verifyAppleIdToken(id_token)
} catch {
return res.status(401).json({ error: 'invalid apple token' })
}
// the nonce must be one this server issued, unconsumed, inside the token, AND
// requested by this same browser session, so a stolen token plus its visible
// nonce cannot be redeemed from anywhere else. Same rule as the handoff:
// verify first, consume only on success, so a failed guess cannot burn it
const issued = nonce && pending.get(nonce)
if (!issued || Date.now() - issued.createdAt > TTL) {
pending.delete(nonce)
return res.status(401).json({ error: 'nonce mismatch' })
}
if (claims.nonce !== nonce || issued.txnId !== req.cookies.apple_txn) {
return res.status(401).json({ error: 'nonce mismatch' })
}
pending.delete(nonce) // consume only after all checks pass
let user = await db.users.findByAppleSub(claims.sub)
if (!user) {
user = await db.users.create({
apple_sub: claims.sub,
email: claims.email || `apple-${claims.sub}@apple.local`, // Apple can withhold the email entirely
name: full_name || '', // only present on the first sign-in
// Apple sends email_verified as a boolean OR the string 'true'
email_verified: claims.email_verified === true || claims.email_verified === 'true',
})
}
const token = await signSession({ sub: user.id }) // your own short-lived session token
// mirror the set options, clearCookie only matches with the same path and domain
res.clearCookie('apple_txn', { httpOnly: true, secure: true, sameSite: 'lax' })
res.set('Cache-Control', 'no-store') // the response carries the session token
res.json({ token })
})
On the client, exchangeAppleToken is the thin caller:
async function exchangeAppleToken(idToken, fullName = '', nonce = '') {
const res = await fetch('/api/auth/apple', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id_token: idToken, full_name: fullName, nonce }), // name only exists on the first sign-in
})
if (!res.ok) throw new Error('apple sign-in failed')
const { token } = await res.json()
saveSession(token) // store your session, then your app treats the user as signed in
}
Supabase is a common shortcut here, since it verifies the Apple id_token for you. If that is your backend, generate a nonce, pass it to AppleID.auth.init, and hand the same nonce to signInWithIdToken, which is what Supabase's own docs recommend:
const nonce = crypto.randomUUID()
window.AppleID.auth.init({
clientId: 'com.yourcompany.yourapp.webauth',
scope: 'name email',
redirectURI: window.location.origin + '/',
usePopup: true,
nonce,
})
const response = await window.AppleID.auth.signIn()
await supabase.auth.signInWithIdToken({ provider: 'apple', token: response.authorization.id_token, nonce })
Firebase's signInWithCredential fills the same slot. The rest of this guide stays backend-agnostic.
Wire it to your button with document.getElementById('apple-signin').addEventListener('click', signInWithApple).
Catch and swallow popup_closed_by_user, it just means the person dismissed the sheet. Historical note: iOS 17.1 briefly rendered an HTML form instead of the native sheet inside WKWebView, fixed in 17.2, with sign-in working throughout.
The Android backend handler
The Android endpoint builds an Apple authorize URL with your apple-callback API route as the redirect, a random single-use state binding the round-trip, and a server-generated nonce that Apple echoes inside the signed token. Shown as Express, one runtime for every endpoint in this guide, adapt the route wiring to your framework:
// one Express backend serves all five auth endpoints in this guide, adapt the
// route wiring if your framework differs
const pending = new Map() // state -> { nonce, code_challenge, createdAt }
const TTL = 5 * 60 * 1000
// POST /api/auth/apple-url
app.post('/api/auth/apple-url', express.json(), (req, res) => {
// never accept the scheme from the client, it is your app's own server constant
const { code_challenge } = req.body || {}
// an S256 challenge is 32 bytes as unpadded base64url, exactly 43 chars
if (typeof code_challenge !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(code_challenge)) {
return res.status(400).json({ error: 'invalid code challenge' })
}
const state = crypto.randomUUID() // random, single-use, binds the round-trip
const nonce = crypto.randomUUID() // Apple echoes this inside the signed id_token
pending.set(state, { nonce, code_challenge, createdAt: Date.now() })
res.set('Cache-Control', 'no-store') // the URL carries live transaction material
const url = 'https://appleid.apple.com/auth/authorize?' + new URLSearchParams({
client_id: 'com.yourcompany.yourapp.webauth', // your Services ID
redirect_uri: 'https://yourapp.com/api/auth/apple-callback', // the form_post endpoint below, registered with Apple
response_type: 'code id_token',
response_mode: 'form_post', // required with name/email scopes, Apple POSTs the result
scope: 'name email',
state,
nonce,
})
res.json({ url })
})
Apple POSTs the result to the apple-callback endpoint below, which verifies it, creates your session, parks that session behind a verifier-bound one-time handoff code, and deeplinks only the code into the app. /auth redeems it over HTTPS.
If your backend is Supabase, its implicit flow is the simplest setup for a WebView-based mobile app, and it is simple for a structural reason. The sign-in crosses several contexts, the WebView, the secure browser, and back, and implicit means the session survives every hop with zero server code on your side: Supabase brokers Apple, mints its own access and refresh token pair, and delivers both straight through the hash and the deeplink, so /auth installs a full self-refreshing session with one setSession() call. No callback endpoint, no exchange, no pending store, the static native-callback.html is the entire return path. Point the broker at it with your scheme in the redirect_to, URL-encoded and allowlisted in Supabase, redirect_to=https://yourapp.com/native-callback.html?deeplink_scheme=yourcompany, and do not lean on Supabase's state for the scheme, Supabase owns that parameter for its own transaction. This is Supabase's documented mobile deep-linking pattern and the right default for most apps; the trade is that the token pair rides the custom scheme, so if your threat model demands the strictest bar, the verifier-bound direct flow above is the upgrade path.
One Apple rule shapes this endpoint, so it deserves a plain statement. Apple hard-requires response_mode: 'form_post' the moment you request the name or email scope, rejecting anything else with invalid_request, and a static callback file cannot read a POST body. So the Return URL for this leg is a backend endpoint, and that is what keeps the name and email scopes on Android: Apple POSTs id_token, state, and, on the very first authorization only, a user JSON field carrying the name. The endpoint validates the state against the pending store, verifies the token, checks the nonce it saved for this exact round-trip, mints your session, parks it under a one-time handoff code, and answers with a tiny HTML bridge page that deeplinks only that code, so no bearer token, Apple's or yours, ever rides the custom scheme. On Node:
const APP_SCHEME = process.env.APP_DEEPLINK_SCHEME // your app name in lowercase, no spaces, exactly as shown in Despia > Publish > Deeplink
if (!/^[a-z][a-z0-9+.-]*$/i.test(APP_SCHEME || '')) throw new Error('APP_DEEPLINK_SCHEME missing or malformed') // fail at startup, not mid-login
// POST target registered with Apple as the redirect_uri
app.post('/api/auth/apple-callback', express.urlencoded({ extended: true }), async (req, res) => {
const { id_token, state, error, user } = req.body // user JSON arrives on the FIRST auth only
// expiring state: unknown or stale round-trips stop here. Verify first,
// consume after all checks pass, deleting up front would let anyone who
// learns a valid state burn the legitimate login with a fake POST
const txn = pending.get(state)
if (!txn || Date.now() - txn.createdAt > TTL) {
pending.delete(state)
return res.status(400).send('unknown or expired state')
}
res.set('Cache-Control', 'no-store') // the bridge page carries a one-time code
const bridge = (q) => '<!DOCTYPE html><html><body><script>location.href="' + APP_SCHEME + '://oauth/auth?' + q + '"</script></body></html>'
if (!id_token) return res.send(bridge('error=' + encodeURIComponent(error || 'no_token')))
let claims
try {
claims = await verifyAppleIdToken(id_token) // same jose helper as the popup path
} catch {
return res.send(bridge('error=invalid_token'))
}
// this nonce was generated server-side for THIS round-trip, so a token minted
// for any other sign-in, app, or replay fails right here
if (claims.nonce !== txn.nonce) return res.send(bridge('error=nonce_mismatch'))
// hybrid response: the code is always returned and c_hash is required, so
// treat their absence as an invalid response, fail closed. Validate the
// binding, then deliberately do not redeem the code, redemption needs a .p8
// client secret and the verified id_token already proves identity
const { code } = req.body
if (!code || !claims.c_hash) return res.send(bridge('error=invalid_hybrid_response'))
const digest = crypto.createHash('sha256').update(code).digest()
const cHash = digest.subarray(0, digest.length / 2).toString('base64url')
if (cHash !== claims.c_hash) return res.send(bridge('error=c_hash_mismatch'))
let fullName = ''
try {
const u = JSON.parse(user || '')
if (u?.name) fullName = `${u.name.firstName || ''} ${u.name.lastName || ''}`.trim()
} catch { /* normal after the first sign-in */ }
const account = await findOrCreateByAppleSub(claims, fullName) // same upsert as the popup endpoint
const token = await signSession({ sub: account.id }) // your own short-lived session
// never deeplink a bearer token. Park the session under a random one-time
// handoff code with a short TTL, bound to the app's PKCE challenge,
// deeplink only the code
const handoff = crypto.randomUUID()
pending.set(handoff, { session: token, code_challenge: txn.code_challenge, createdAt: Date.now() })
// consume the state only now, after the account, session, and handoff all
// exist, so an infrastructure hiccup between validation and here cannot eat
// a valid login. In Redis, atomically mark the state claimed right after
// cryptographic verification and keep the steps below idempotent, so two
// identical callbacks cannot race through account creation
pending.delete(state)
res.send(bridge('handoff_code=' + encodeURIComponent(handoff)))
})
// POST /api/auth/handoff, the app trades the one-time code for the session over HTTPS
app.post('/api/auth/handoff', express.json(), (req, res) => {
const { code, verifier } = req.body
if (typeof code !== 'string' || typeof verifier !== 'string') {
return res.status(400).json({ error: 'invalid request' })
}
const txn = pending.get(code)
if (!txn?.session || Date.now() - txn.createdAt > TTL) {
pending.delete(code) // expired entries can go
return res.status(401).json({ error: 'invalid or expired code' })
}
// PKCE check: an interceptor may have the code, only the real app has the
// verifier. Verify BEFORE consuming, deleting on a failed guess would let an
// interceptor burn the legitimate handoff and lock the real app out
const challenge = crypto.createHash('sha256').update(verifier, 'ascii').digest('base64url')
const a = Buffer.from(challenge)
const b = Buffer.from(txn.code_challenge || '')
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ error: 'verifier mismatch' })
}
pending.delete(code) // consume only after successful proof
res.set('Cache-Control', 'no-store')
res.json({ token: txn.session })
})
In this direct flow, everything that matters is bound server-side: the scheme is your own constant, the state is random and single-use, the nonce lives only in the pending store and inside Apple's signed token, and the deeplink carries nothing but a one-time code that expires in minutes, works once, and is useless without the verifier, which never travels through Apple's browser flow or the custom-scheme deeplink and is disclosed only to your own backend over HTTPS at redemption. Custom-scheme URLs can be claimed by other apps, which is exactly why no bearer credential travels through one here, and why the code alone is not enough to redeem. Production hygiene for the pending store: use Redis or a database with native TTL expiry rather than an in-memory map, sweep abandoned entries, rate limit the public apple-nonce, apple-url, and handoff endpoints, since anyone can call them, and in a distributed store claim state, nonce, and handoff records atomically, a Redis transaction or Lua script rather than separate get and delete calls, so two racing requests cannot redeem the same record. One naming footnote for precision: the verifier and challenge here are PKCE-style protection for the app handoff, applied by your own backend, Apple's authorization server is not running PKCE in this flow. The popup nonce is bound to the browser session through the HttpOnly apple_txn cookie above, so only the session that requested it can redeem it, keep that binding, it is load-bearing. Two footnotes. If you skip the name and email scopes entirely, Apple permits response_mode: 'fragment' and a static callback file works, a lighter setup when sub-based identity is enough. And if Supabase brokers your Android flow, none of this applies: Apple form_posts to Supabase's server callback, and your static native-callback.html receives Supabase's token pair in the hash exactly as described.
The callback layer, three files that work together
The Android bridge leg does not hand tokens back through one file. It is three cooperating pieces, and getting all three right is what makes native sign-in reliable. Miss any one and the session silently vanishes. iOS and web never touch this layer, they exchange the id_token inline from the SDK popup. This is only the Android return path, and it crosses two separate browser contexts, so keep the storage model straight. The Chrome Custom Tab and the WebView have completely separate sessionStorage. The verifier is written in the WebView before the tab opens, and it is read back in the WebView on /auth after the deeplink closes the tab, the same context both times. The tab itself never needs storage access: Apple's screen and the server-rendered bridge page only pass data through the URL, which is exactly why the design survives the context split. Do not try to read sessionStorage inside the callback or bridge page, it is a different browser and the value is not there. One edge case follows from this: if Android kills the app process while the tab is open, the WebView's sessionStorage dies with it, the verifier is gone, and the handoff redemption fails with the expired error, the user simply signs in again.
With the form_post bridge above, the direct Apple leg returns through the apple-callback endpoint, not this file, and the deeplink carries only the one-time handoff_code. The static file below is the return page for the Supabase-brokered variant, where the hash carries the access and refresh token pair, and the boot-capture and /auth pieces are shared by both, they just read different field names from the deeplink.
1. public/native-callback.html, the return page
The in-app browser redirects here. Supabase returns its own access and refresh token pair in the URL hash. This static page reads the hash, forwards the tokens through the oauth/ deeplink, and offers a tappable fallback for schemes that need a user gesture. It must be a real static file at public/native-callback.html, not a route your framework renders.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Signing you in…</title>
</head>
<body style="margin:0;background:#0d0c10;color:#f4f2ee;font-family:-apple-system,sans-serif;text-align:center">
<p style="margin-top:42vh;font-size:15px">Signing you in…</p>
<p><a id="continue" style="display:none;color:#ff5c1f;font-size:16px;font-weight:600;text-decoration:none">Continue to app</a></p>
<script>
// Supabase returns the session in the URL hash: #access_token=...&refresh_token=...
// The app scheme is carried explicitly in the allowlisted redirect_to query,
// never taken from Supabase's state, Supabase owns that parameter.
var q = new URLSearchParams(location.search);
var hash = new URLSearchParams(location.hash.slice(1));
var scheme = q.get('deeplink_scheme') || '';
var accessToken = hash.get('access_token') || '';
var refreshToken = hash.get('refresh_token') || '';
var error = hash.get('error') || q.get('error') || '';
var query = accessToken
? 'access_token=' + encodeURIComponent(accessToken) + '&refresh_token=' + encodeURIComponent(refreshToken)
: 'error=' + encodeURIComponent(error || 'no_token');
if (scheme && /^[a-z][a-z0-9+.-]*$/i.test(scheme)) {
// Native: hand the tokens to the app via its deep link (Despia path oauth/auth).
var deeplink = scheme + '://oauth/auth?' + query;
location.href = deeplink;
// Custom schemes can require a user gesture — offer a tappable fallback.
var btn = document.getElementById('continue');
btn.href = deeplink;
btn.style.display = 'inline-block';
} else {
// Web or missing deeplink_scheme: continue to the same-origin /auth page.
location.replace('/auth?' + query);
}
</script>
</body>
</html>
The oauth/ prefix is not optional: yourcompany://oauth/auth closes the in-app browser and reopens your app at /auth, while yourcompany://auth without it does nothing and strands the user in the browser.
2. Boot-time capture, before the app mounts
When Despia reopens the app, the deeplink lands as a history URL change on any path, often /, and the WebView does not reload. If a protected-root guard runs first, it can bounce to /login before your code reads the tokens. So capture them before the app mounts, stash them, and normalize the URL to /auth.
// run this before your app renders (main entry file)
const p = new URLSearchParams(location.search)
const h = new URLSearchParams(location.hash.slice(1))
// covers both variants: the one-time handoff code (direct Apple) and the
// Supabase token pair
const hasToken =
p.get('handoff_code') || h.get('handoff_code') ||
p.get('access_token') || h.get('access_token') ||
p.get('error') || h.get('error')
if (hasToken) {
sessionStorage.setItem('pending_oauth', location.search + location.hash)
window.history.replaceState(null, '', '/auth')
}
3. /auth, install the session
/auth reads whatever was stashed at boot, or the live URL if nothing was stashed, because on the native leg the tokens can arrive as a later history change while /auth is already mounted. It watches popstate and hashchange for that, then installs the session with setSession(), which supabase-js keeps refreshing.
// read WITHOUT deleting: the stash and the verifier survive until the session
// is actually installed, a network blip must not destroy the only copy of the proof
function peek() {
const stash = sessionStorage.getItem('pending_oauth')
const src = stash || (location.search + location.hash)
const p = new URLSearchParams(src.split('#')[0].replace(/^\?/, ''))
const h = new URLSearchParams((src.split('#')[1] || ''))
return {
handoffCode: p.get('handoff_code') || h.get('handoff_code'), // direct Apple, minted in apple-callback
accessToken: p.get('access_token') || h.get('access_token'), // Supabase-brokered variant
refreshToken: p.get('refresh_token') || h.get('refresh_token') || '',
error: p.get('error') || h.get('error'),
}
}
function clearPending() {
sessionStorage.removeItem('pending_oauth')
sessionStorage.removeItem('apple_verifier')
}
let completing = false // popstate, hashchange, and the initial call can overlap
async function completeSignIn() {
if (completing) return
completing = true
try {
const { handoffCode, accessToken, refreshToken, error } = peek()
if (error) { clearPending(); return goToLogin(error) }
// direct Apple variant: trade the one-time code plus the secret verifier for the session
if (handoffCode) {
const verifier = sessionStorage.getItem('apple_verifier') || ''
const res = await fetch('/api/auth/handoff', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: handoffCode, verifier }),
})
if (!res.ok) return goToLogin('sign-in expired, try again')
const { token } = await res.json()
saveSession(token)
clearPending() // only now, the session is installed
return goHome()
}
// Supabase-brokered variant: install the token pair
if (accessToken) {
const { error: sessErr } = await supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken,
})
if (sessErr) return goToLogin(sessErr.message)
clearPending() // only now, the session is installed
return goHome()
}
// nothing present yet: keep watching, the token may still be arriving
} finally {
completing = false
}
}
completeSignIn()
window.addEventListener('popstate', completeSignIn)
window.addEventListener('hashchange', completeSignIn) // hash-only change, popstate does not fire
Wrap the three functions above in your framework's page component, goHome and goToLogin are your router's navigate calls. iOS and web skip all of this, they call signInWithIdToken directly from the popup callback.
The things that trip people up
usePopup: true is non-negotiable on iOS, redirect mode shows a blank white screen inside the WebView and fails review. The SDK script tag must load before your app script, and only over HTTPS, which rules out the HTTP Local Server offline mode, switch to PWA offline support instead. The redirectURI passed to AppleID.auth.init must match a registered return URL character for character, trailing slash included. Apple returns the user's name only on the very first authorization, so persist it then. And the Android leg still lives or dies by the oauth/ deeplink prefix. In the Supabase-brokered variant that deeplink is fired by the static native-callback.html; in the direct Apple variant it is fired by the HTML your apple-callback endpoint returns.
Ship both logins together
Apple beside Google is the pairing iOS review expects, and here they share one bridge, one callback file, and one /auth. Take your web app to iOS and Android with both sign-ins built for review, from the single codebase you already ship. Code signing and submission run from the browser, no Mac and no CLI.





