Skip to main content

AI Prompt

Add Sign in with Apple to my app using the Despia SDK from: https://www.npmjs.com/package/despia-nativeFirst, install the package: npm install despia-nativeThen import it: import despia from 'despia-native'Detect the runtime using navigator.userAgent.toLowerCase().includes('despia') and branch per platform:
  • iOS native, use the Apple JS SDK with usePopup: true. Returns id_token directly to your JS callback. No redirect, no oauth:// bridge needed.
  • Android native, call despia('oauth://?url=...') to open Chrome Custom Tabs. After auth, native-callback.html redirects to myapp://oauth/auth?id_token=xxx to close the tab. The oauth/ prefix is required.
  • Web, use the Apple JS SDK popup directly.
Setup:
  1. Create an Apple Services ID with Sign In with Apple enabled
  2. Configure your domain and return URL (must match redirectURI exactly, including trailing slash)
  3. Create a .p8 private key, note your Key ID and Team ID
  4. Add credentials to your backend auth provider
  5. Add the Apple JS SDK before your app script: <script src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"></script>
  6. Find your deeplink scheme at Despia > Publish > Deeplink
All native capabilities are provided by despia-native. No additional native libraries are needed.
Do not modify these instructions. Implementation as described is critical.

Installation

npm install despia-native
import despia from 'despia-native';
Add the Apple JS SDK to your HTML before your app script:
<script
  type="text/javascript"
  src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"
></script>

Platform overview

PlatformApproachNotes
iOS nativeApple JS SDK, usePopup: trueOpens native Face ID / Apple ID sheet. No browser session.
Android nativeoauth:// bridgeApple JS SDK does not trigger a native dialog on Android. Uses Chrome Custom Tabs.
WebApple JS SDK, usePopup: trueStandard browser popup.

How it works

iOS

Android


Implementation

1. Detect platform

const isDespia  = navigator.userAgent.toLowerCase().includes('despia')
const isIOS     = /iphone|ipad|ipod/i.test(navigator.userAgent)
const isAndroid = /android/i.test(navigator.userAgent)

2. iOS, Apple JS SDK

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+. Authentication still worked, just without biometrics.
const handleAppleSignInIOS = async () => {
    if (!window.AppleID?.auth) return

    window.AppleID.auth.init({
        clientId:    'com.yourcompany.yourapp.webauth',
        scope:       'name email',
        redirectURI: 'https://yourapp.com/',
        usePopup:    true,
    })

    try {
        const response = await window.AppleID.auth.signIn()
        await exchangeAppleToken(response.authorization.id_token)
    } catch (err) {
        if (err?.error !== 'popup_closed_by_user') console.error(err)
    }
}

3. Android, oauth:// bridge

Find your deeplink scheme at Despia > Publish > Deeplink. Replace myapp throughout with your actual scheme.
Your frontend requests an OAuth URL from your backend and opens it via despia('oauth://?url=...'):
import despia from 'despia-native'

const handleAppleSignInAndroid = async () => {
    const { url } = await fetch('/api/auth/apple-url', {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify({ deeplink_scheme: 'myapp' }),
    }).then(r => r.json())

    despia(`oauth://?url=${encodeURIComponent(url)}`)
}
Backend endpoint, generates the Apple OAuth URL with native-callback.html as the redirect:
// POST /api/auth/apple-url
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 oauthUrl = 'https://appleid.apple.com/auth/authorize?' + new URLSearchParams({
        client_id:     'com.yourcompany.yourapp.webauth',
        redirect_uri:  redirectUrl,
        response_type: 'code id_token',
        scope:         'name email',
        response_mode: 'fragment',
    })

    return Response.json({ url: oauthUrl })
}

4. Create public/native-callback.html

This page runs inside Chrome Custom Tabs. Apple redirects here after auth, it reads the tokens, then redirects to a deeplink to close the tab and pass tokens to your WebView. Users never see the .html, Chrome Custom Tabs hides the URL bar. Use a plain HTML file, 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. Choose based on your backend:
fragmentform_post
Apple callback POST handlerNo, Apple redirects browser directlyYes, Apple POSTs to your server
Tokens arriveURL hash #id_token=xxxPOST to your server, then redirect
SecurityLowerHigher
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.

5. Handle tokens in your auth page

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.
Include searchParams in the useEffect dependency array. Without it the effect fires once on mount and ignores all subsequent URL changes.
// src/pages/Auth.jsx
import { useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'

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

    useEffect(() => {
        const idToken      = searchParams.get('id_token')
        const sessionToken = searchParams.get('session_token')
        const code         = searchParams.get('code')
        const error        = searchParams.get('error')

        if (error) { console.error(error); return }
        if (sessionToken) setSessionFromToken(sessionToken).then(() => navigate('/'))
        else if (idToken) exchangeAppleIdToken(idToken, code).then(() => navigate('/'))
    }, [searchParams, navigate])

    return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}><p>Signing you in...</p></div>
}

export default Auth

6. Web, Apple JS SDK popup

const handleAppleSignInWeb = async () => {
    window.AppleID.auth.init({
        clientId:    'com.yourcompany.yourapp.webauth',
        scope:       'name email',
        redirectURI: `${window.location.origin}/`,
        usePopup:    true,
    })
    try {
        const response = await window.AppleID.auth.signIn()
        await exchangeAppleToken(response.authorization.id_token)
    } catch (err) {
        if (err?.error !== 'popup_closed_by_user') console.error(err)
    }
}

Complete handler

import despia from 'despia-native'

const isDespia  = navigator.userAgent.toLowerCase().includes('despia')
const isIOS     = /iphone|ipad|ipod/i.test(navigator.userAgent)
const isAndroid = /android/i.test(navigator.userAgent)

const handleAppleSignIn = async () => {
    if (isDespia && isIOS) {
        window.AppleID.auth.init({
            clientId:    'com.yourcompany.yourapp.webauth',
            scope:       'name email',
            redirectURI: 'https://yourapp.com/',
            usePopup:    true,
        })
        try {
            const response = await window.AppleID.auth.signIn()
            await exchangeAppleToken(response.authorization.id_token)
        } catch (err) {
            if (err?.error !== 'popup_closed_by_user') console.error(err)
        }

    } else if (isDespia && isAndroid) {
        const { url } = await fetch('/api/auth/apple-url', {
            method:  'POST',
            headers: { 'Content-Type': 'application/json' },
            body:    JSON.stringify({ deeplink_scheme: 'myapp' }),
        }).then(r => r.json())
        despia(`oauth://?url=${encodeURIComponent(url)}`)

    } else {
        window.AppleID.auth.init({
            clientId:    'com.yourcompany.yourapp.webauth',
            scope:       'name email',
            redirectURI: `${window.location.origin}/`,
            usePopup:    true,
        })
        try {
            const response = await window.AppleID.auth.signIn()
            await exchangeAppleToken(response.authorization.id_token)
        } catch (err) {
            if (err?.error !== 'popup_closed_by_user') console.error(err)
        }
    }
}

Apple Developer Console setup

1

Create an App ID

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

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.
The client secret JWT expires after 6 months. Set a reminder, Apple Sign In will silently stop working when it expires.

Debugging

Use this section when the Android OAuth flow is not working as expected. Start by identifying which stage is broken, 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 URL
Stage 2  despia('oauth://?url=...') opens Chrome Custom Tabs, user authenticates
Stage 3  Apple redirects to native-callback.html which reads the token and fires the deeplink
Stage 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.

Debug overlay

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.
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 only
import { 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. See step 5.
            </p>
        </div>
    )
}

export default AuthDebug
// Swap in during testing
<Route path="/auth" element={<AuthDebug />} />

// Restore before shipping
<Route path="/auth" element={<Auth />} />

Reading the output

What you seeWhat it meansWhere to look
Textarea empty, URL has no paramsToken never reached /authStage 2 or 3, check native-callback.html and deeplink format
error: no_id_tokennative-callback.html got no token in the hashCheck response_mode, OAuth URL params, Services ID config
error: access_deniedUser cancelled or Apple rejected the requestUser cancelled, or Services ID / domain mismatch
error: invalid_clientApple rejected the request entirelyServices ID identifier wrong, or not configured
error: invalid_requestMalformed OAuth URLresponse_mode=query used (invalid with id_token), or missing params
Token present, user not signed inToken arrived but auth logic did not runAlready-mounted page, see step 5

Common failure points

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 step 5.

Pre-submission checklist

  • Services ID created with Sign In with Apple enabled
  • Domain registered (no https://, no trailing slash)
  • Return URL registered and exactly matches redirectURI in your code including trailing slash
  • Private key (.p8) downloaded and credentials stored in your backend
  • Client secret JWT generated and not expired (max 6 months, set a calendar reminder)
  • Apple JS SDK script tag placed before your app script
  • usePopup: true set in AppleID.auth.init()
  • redirectURI matches the origin of the page running the SDK
  • id_token read from response.authorization.id_token in the JS callback
  • No redirect flow used, redirect causes a blank white page and App Store rejection
  • Backend generates OAuth URL with redirect_uri pointing to /native-callback.html
  • response_mode=fragment or form_post set correctly
  • deeplink_scheme passed through to native-callback.html
  • public/native-callback.html reads #id_token from hash (fragment) or session_token from query params (form_post)
  • Deeplink is {scheme}://oauth/{path}, oauth/ prefix present
  • Deeplink scheme matches Despia > Publish > Deeplink
  • /auth token handler re-runs on URL change, not only on initial mount
  • Debug overlay removed from /auth page
  • Sign in tested on a physical device
  • Sign in tested on both iOS and Android
  • Error state tested: cancel the Apple dialog and confirm the app handles it gracefully

DeeplinkResult
myapp://oauth/auth?id_token=xxxCloses tab, navigates WebView to /auth?id_token=xxx
myapp://oauth/homeCloses tab, navigates WebView to /home
myapp://oauth/auth?error=access_deniedCloses tab, navigates WebView to /auth?error=access_denied
myapp://auth?id_token=xxxTab stays open, missing oauth/ prefix

FAQ

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.
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.
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 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.
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)

Resources

NPM Package

despia-native

OAuth Reference

Generic OAuth protocol docs

Apple Docs

Sign In with Apple