
This setup is more technical and you'll need to paste the following prompt, which includes full scaffolding, into Lovable to get good results:
# Despia Native SDK - Apple Sign In Integration Guide
Production ready implementation for Lovable + Supabase (Custom Provider) using redirect flow
---
## What This Does
This implements Apple Sign In that works in:
1. Web browsers - Using redirect flow with form_post
2. Native mobile apps - When your Lovable web app is wrapped with Despia SDK
### Why Custom Implementation?
While Supabase has Apple OAuth support, this custom implementation gives you:
1. Full control over the authentication flow
2. Custom user metadata handling
3. Consistent experience across web and native
4. Private email relay handling for users who hide their email
### Apple Sign In Flow Overview
```
User clicks "Sign in with Apple"
-> Browser redirects to Apple authorization page
-> User authorizes with Face ID/Touch ID/password
-> Apple POSTs to edge function (form_post required for name/email scopes)
-> Edge function verifies identity token with Apple's public keys
-> Edge function creates/authenticates Supabase user
-> Edge function redirects to frontend with tokens in URL
-> Frontend calls setSession() with tokens
-> User logged in
```
---
## Architecture
### Web Flow
```
LoginButton.tsx
-> window.location.href = Apple OAuth URL
-> User authenticates with Apple
-> Apple POSTs { id_token, code, user } to edge function
-> Edge function does EVERYTHING:
1. Parse form POST data
2. Verify Apple identity token (JWT)
3. Extract user info from token
4. Create/find Supabase user
5. Generate magic link + verify OTP = real session
6. Redirect to /auth?access_token=xxx&refresh_token=yyy
-> Auth.tsx extracts tokens from URL
-> Auth.tsx calls setSession() with tokens
-> User logged in
```
### Native Flow (Despia WebView)
```
LoginButton.tsx
-> Detect native environment
-> despia('oauth://?url=...') opens ASWebAuthenticationSession
-> User authenticates with Apple (native UI)
-> Apple POSTs to edge function
-> Edge function processes and redirects to /auth with tokens + state
-> Edge function detects native state, redirects to deeplink
-> myapp://oauth/auth?access_token=xxx (closes browser)
-> WebView navigates to /auth?access_token=xxx
-> Auth.tsx calls setSession() with tokens
-> User logged in
```
---
## CRITICAL: Why This Works (Lessons Learned)
### WRONG: Manual JWT Creation
```typescript
// This WILL NOT WORK - creates "bad_jwt" errors
import * as jose from "jose";
const jwt = await new jose.SignJWT({ sub: userId, ... }).sign(secret);
```
Supabase tokens have specific claims, signatures, and internal state that manual JWTs don't have.
### CORRECT: Magic Link + OTP Verification
```typescript
// This creates REAL Supabase tokens that work everywhere
const { data: linkData } = await supabaseAdmin.auth.admin.generateLink({
type: 'magiclink',
email: userEmail,
});
const { data: sessionData } = await supabasePublic.auth.verifyOtp({
token_hash: linkData.properties.hashed_token,
type: 'email',
});
// sessionData.session.access_token = real, working token!
```
### Key Requirements
1. **Two Supabase clients** in the edge function:
- `supabaseAdmin` (service role key) - for creating users, generating links
- `supabasePublic` (anon key) - for verifying OTP to create session
2. **Always set `email_confirm: true`** when creating OAuth users
3. **Use `setSession()`** on the frontend, not just storing tokens
4. **`response_mode: 'form_post'`** - Required by Apple when requesting name/email scopes
5. **Edge function handles POST** - Apple POSTs form data, edge function redirects to frontend
---
## CRITICAL: Required Components
| Component | What It Does | Without It |
|-----------|--------------|------------|
| **VITE_APPLE_CLIENT_ID** (.env) | Your Apple Service ID (frontend) | ⚠️ Console warning, auth fails |
| **VITE_SUPABASE_URL** (.env) | Your Supabase project URL (frontend) | ⚠️ Console warning, wrong redirects |
| **APPLE_CLIENT_ID** (Supabase) | Your Apple Service ID (backend) | Token verification fails |
| **APPLE_TEAM_ID** (Supabase) | Your Apple Developer Team ID | Token verification fails |
| **APPLE_KEY_ID** (Supabase) | Your Apple Sign In Key ID | Client secret generation fails |
| **APPLE_PRIVATE_KEY** (Supabase) | Your Apple .p8 key contents | Can't generate client secret |
| **APP_URL** (Supabase) | Your frontend URL for redirects | Wrong redirect after auth |
| auth-apple-callback | Edge function - receives POST, redirects | Can't complete login |
| LoginButton.tsx | Starts Apple Sign In flow | Nothing happens on click |
| Auth.tsx | Receives tokens, sets session | Login doesn't complete |
---
## Complete File List
| # | File Path | Purpose |
|---|-----------|---------|
| 1 | .env | Frontend environment variables |
| 2 | src/vite-env.d.ts | TypeScript types for env variables |
| 3 | supabase/functions/auth-apple-callback/index.ts | Edge function - receives Apple POST, redirects to frontend |
| 4 | src/lib/apple-auth.ts | Apple auth helper functions |
| 5 | src/components/LoginButton.tsx | Login button - redirects to Apple |
| 6 | src/pages/Auth.tsx | Receives tokens from URL, sets session |
| 7 | src/App.tsx | Route configuration |
| 8 | supabase/config.toml | Edge function config |
| 9 | public/404.html | SPA fallback |
| 10 | src/components/SpaRedirector.tsx | Handle 404 redirect |
| 11 | public/_redirects | Netlify/Lovable routing |
---
## Step 1: Set Up Apple Developer Account
### 1.1 Create an App ID
1. Go to https://developer.apple.com/account/resources/identifiers/list
2. Click the **+** button to create a new identifier
3. Select **App IDs** and click **Continue**
4. Select **App** type and click **Continue**
5. Fill in:
- **Description**: Your app name (e.g., "My Awesome App")
- **Bundle ID**: Explicit, e.g., `com.yourcompany.yourapp`
6. Scroll down to **Capabilities** and check **Sign In with Apple**
7. Click **Continue** then **Register**
### 1.2 Create a Service ID (for web)
1. Go to https://developer.apple.com/account/resources/identifiers/list
2. Click the **+** button
3. Select **Services IDs** and click **Continue**
4. Fill in:
- **Description**: "My App Web Auth"
- **Identifier**: `com.yourcompany.yourapp.web` (must be different from App ID)
5. Click **Continue** then **Register**
6. Click on the newly created Service ID
7. Check **Sign In with Apple** and click **Configure**
8. Configure:
- **Primary App ID**: Select the App ID you created
- **Domains and Subdomains**:
- `your-app.lovable.app`
- `your-project-ref.supabase.co` (for edge function)
- **Return URLs**:
- `https://your-project-ref.supabase.co/functions/v1/auth-apple-callback`
9. Click **Save** then **Continue** then **Save**
> **Important**: The Return URL must be your Supabase edge function URL because Apple uses `response_mode: form_post` which POSTs directly to your server. The edge function then redirects to your frontend.
### 1.3 Create a Sign In with Apple Key
1. Go to https://developer.apple.com/account/resources/authkeys/list
2. Click the **+** button
3. Fill in:
- **Key Name**: "Sign In with Apple Key"
4. Check **Sign In with Apple** and click **Configure**
5. Select your **Primary App ID** and click **Save**
6. Click **Continue** then **Register**
7. **IMPORTANT**: Download the `.p8` key file (you can only download it once!)
8. Note the **Key ID** displayed
### 1.4 Note Your Credentials
You'll need these values:
- **Team ID**: Found in top right of Apple Developer portal (e.g., `ABCD1234EF`)
- **Service ID**: The identifier you created (e.g., `com.yourcompany.yourapp.web`)
- **Key ID**: From the key you created (e.g., `ABC123DEFG`)
- **Private Key**: Contents of the `.p8` file
---
## Step 2: Add Environment Variables
### Frontend Configuration (.env file)
Create a `.env` file in your project root:
```env
VITE_APPLE_CLIENT_ID=com.yourcompany.yourapp.web
VITE_SUPABASE_URL=https://your-project-ref.supabase.co
```
**This is safe** because the client_id is public (it's visible in the OAuth flow anyway).
> **Note**: In Lovable, you can set environment variables in Project Settings → Environment Variables.
### In Supabase Dashboard (Server-side Secrets)
Go to Project Settings → Edge Functions → Add secrets:
```
APPLE_CLIENT_ID=com.yourcompany.yourapp.web
APPLE_TEAM_ID=ABCD1234EF
APPLE_KEY_ID=ABC123DEFG
APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIGT...your key content...AB12\n-----END PRIVATE KEY-----
APP_URL=https://your-app.lovable.app
```
**IMPORTANT**: For the private key:
1. Open the `.p8` file in a text editor
2. Copy the entire contents including `-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----`
3. Replace actual newlines with `\n` for the environment variable
---
## Step 2.5: Create .env File
Create a `.env` file in your project root:
```env
# Apple Sign In Configuration
VITE_APPLE_CLIENT_ID=com.yourcompany.yourapp.web
VITE_SUPABASE_URL=https://your-project-ref.supabase.co
```
For TypeScript support, create or update `src/vite-env.d.ts`:
```typescript
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APPLE_CLIENT_ID: string;
readonly VITE_SUPABASE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
```
> **Lovable Users**: Set these in Project Settings → Environment Variables instead of a `.env` file.
---
## Step 3: Install Dependencies
```bash
npm install despia-native
```
> Note: `jose` is only needed in the edge function (Deno), not in the frontend.
---
## Step 4: Create Apple Auth Callback Edge Function
File: `supabase/functions/auth-apple-callback/index.ts`
This edge function:
1. Receives POST from Apple (form_post response mode)
2. Verifies the Apple identity token using Apple's public keys
3. Creates or finds the Supabase user
4. Generates a real Supabase session using magic link + OTP verification
5. Redirects to frontend with tokens in URL
```typescript
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import * as jose from "https://deno.land/x/jose@v4.14.4/index.ts";
// Cache for Apple's public keys
let applePublicKeys: jose.JWTVerifyGetKey | null = null;
let keysLastFetched = 0;
const KEYS_CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
async function getApplePublicKeys(): Promise<jose.JWTVerifyGetKey> {
const now = Date.now();
if (applePublicKeys && (now - keysLastFetched) < KEYS_CACHE_DURATION) {
return applePublicKeys;
}
applePublicKeys = jose.createRemoteJWKSet(new URL('https://appleid.apple.com/auth/keys'));
keysLastFetched = now;
return applePublicKeys;
}
async function verifyAppleToken(idToken: string, clientId: string): Promise<jose.JWTPayload> {
const JWKS = await getApplePublicKeys();
const { payload } = await jose.jwtVerify(idToken, JWKS, {
issuer: 'https://appleid.apple.com',
audience: clientId,
});
return payload;
}
function redirectWithError(appUrl: string, error: string, isNative: boolean, deeplinkScheme?: string): Response {
const errorEncoded = encodeURIComponent(error);
if (isNative && deeplinkScheme) {
// Native: redirect to deeplink to close ASWebAuthenticationSession
return new Response(null, {
status: 302,
headers: { 'Location': `${deeplinkScheme}://oauth/auth?error=${errorEncoded}` }
});
}
// Web: redirect to frontend auth page with error
return new Response(null, {
status: 302,
headers: { 'Location': `${appUrl}/auth?error=${errorEncoded}` }
});
}
serve(async (req) => {
const appUrl = Deno.env.get('APP_URL')!;
const clientId = Deno.env.get('APPLE_CLIENT_ID')!;
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const anonKey = Deno.env.get('SUPABASE_ANON_KEY')!;
// Apple sends POST with form data (response_mode: form_post)
if (req.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
try {
// Parse form data from Apple
const formData = await req.formData();
const idToken = formData.get('id_token') as string;
const code = formData.get('code') as string;
const state = formData.get('state') as string;
const userJson = formData.get('user') as string; // Only on first auth
const errorParam = formData.get('error') as string;
// Parse state to check if native flow
// State format: "uuid" for web, "uuid|deeplinkScheme" for native
let isNative = false;
let deeplinkScheme: string | undefined;
if (state && state.includes('|')) {
const parts = state.split('|');
isNative = true;
deeplinkScheme = parts[1];
}
// Handle Apple errors
if (errorParam) {
console.error('Apple auth error:', errorParam);
return redirectWithError(appUrl, errorParam, isNative, deeplinkScheme);
}
if (!idToken) {
return redirectWithError(appUrl, 'No identity token received', isNative, deeplinkScheme);
}
console.log('Step 1: Verifying Apple identity token...');
// ============================================
// STEP 1: Verify Apple identity token
// ============================================
let tokenPayload: jose.JWTPayload;
try {
tokenPayload = await verifyAppleToken(idToken, clientId);
} catch (verifyError) {
console.error('Token verification failed:', verifyError);
return redirectWithError(appUrl, 'Invalid identity token', isNative, deeplinkScheme);
}
// Extract user info from token
const appleUserId = tokenPayload.sub as string;
const email = tokenPayload.email as string | undefined;
const emailVerified = tokenPayload.email_verified as boolean | undefined;
const isPrivateEmail = tokenPayload.is_private_email as boolean | string | undefined;
console.log('Step 2: Processing user info...');
// ============================================
// STEP 2: Process user info
// ============================================
// Apple only sends user info (name) on FIRST authorization
let displayName = 'Apple User';
let firstName = '';
let lastName = '';
// Parse user JSON if provided (first sign-in only)
if (userJson) {
try {
const userData = JSON.parse(userJson);
if (userData.name) {
firstName = userData.name.firstName || '';
lastName = userData.name.lastName || '';
displayName = [firstName, lastName].filter(Boolean).join(' ') || 'Apple User';
}
} catch (e) {
console.warn('Failed to parse user JSON:', e);
}
}
// Generate a deterministic email for Supabase if user hid their email
const userEmail = email || `${appleUserId}@apple.oauth`;
console.log('Step 3: Creating/finding Supabase user...');
// ============================================
// STEP 3: Create or find Supabase user
// ============================================
const supabaseAdmin = createClient(supabaseUrl, serviceRoleKey, {
auth: { autoRefreshToken: false, persistSession: false }
});
const supabasePublic = createClient(supabaseUrl, anonKey);
let userId: string;
// Try to create user first
const { data: createData, error: createError } = await supabaseAdmin.auth.admin.createUser({
email: userEmail,
email_confirm: true, // CRITICAL: Auto-confirm for OAuth users
user_metadata: {
apple_user_id: appleUserId,
display_name: displayName,
full_name: displayName,
first_name: firstName,
last_name: lastName,
email_verified: emailVerified,
is_private_email: isPrivateEmail,
provider: 'apple'
}
});
if (createError) {
if (createError.message?.includes('already been registered')) {
console.log('User exists, fetching...');
const { data: existingUsers } = await supabaseAdmin.auth.admin.listUsers();
const existingUser = existingUsers?.users.find(u => u.email === userEmail);
if (!existingUser) {
const userByAppleId = existingUsers?.users.find(
u => u.user_metadata?.apple_user_id === appleUserId
);
if (!userByAppleId) {
return redirectWithError(appUrl, 'User not found', isNative, deeplinkScheme);
}
userId = userByAppleId.id;
} else {
userId = existingUser.id;
}
// Update user metadata if we have new name info
if (firstName || lastName) {
await supabaseAdmin.auth.admin.updateUserById(userId, {
user_metadata: {
apple_user_id: appleUserId,
display_name: displayName,
full_name: displayName,
first_name: firstName,
last_name: lastName,
provider: 'apple'
}
});
}
} else {
console.error('Failed to create user:', createError);
return redirectWithError(appUrl, 'Failed to create user', isNative, deeplinkScheme);
}
} else {
userId = createData.user.id;
}
console.log('Step 4: Generating Supabase session via magic link...');
// ============================================
// STEP 4: Generate real Supabase session
// ============================================
const { data: linkData, error: linkError } = await supabaseAdmin.auth.admin.generateLink({
type: 'magiclink',
email: userEmail,
});
if (linkError || !linkData) {
console.error('Failed to generate magic link:', linkError);
return redirectWithError(appUrl, 'Failed to generate session', isNative, deeplinkScheme);
}
console.log('Step 5: Verifying OTP to create session...');
const { data: sessionData, error: sessionError } = await supabasePublic.auth.verifyOtp({
token_hash: linkData.properties.hashed_token,
type: 'email',
});
if (sessionError || !sessionData.session) {
console.error('Failed to verify OTP:', sessionError);
return redirectWithError(appUrl, 'Failed to create session', isNative, deeplinkScheme);
}
console.log('Success! User logged in:', userId);
// ============================================
// STEP 5: Redirect to frontend with tokens
// ============================================
const accessToken = sessionData.session.access_token;
const refreshToken = sessionData.session.refresh_token;
const params = new URLSearchParams({
access_token: accessToken,
refresh_token: refreshToken,
});
if (isNative && deeplinkScheme) {
// Native: redirect to deeplink to close ASWebAuthenticationSession
// The deeplink will navigate the WebView to /auth with tokens
return new Response(null, {
status: 302,
headers: { 'Location': `${deeplinkScheme}://oauth/auth?${params.toString()}` }
});
}
// Web: redirect directly to frontend auth page
return new Response(null, {
status: 302,
headers: { 'Location': `${appUrl}/auth?${params.toString()}` }
});
} catch (error) {
console.error('Error:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
// Can't determine native state, default to web redirect
return new Response(null, {
status: 302,
headers: { 'Location': `${appUrl}/auth?error=${encodeURIComponent(message)}` }
});
}
});
```
---
## Step 6: Configure Edge Function
File: `supabase/config.toml`
```toml
[functions.auth-apple-callback]
verify_jwt = false
```
---
## Step 7: Create Apple Auth Helper
File: `src/lib/apple-auth.ts`
> ⚠️ **AI CODING AGENTS: ENVIRONMENT VARIABLES REQUIRED**
>
> This file reads configuration from environment variables. Make sure to set:
>
> - `VITE_APPLE_CLIENT_ID`: Your Apple Service ID from Apple Developer Portal
> - `VITE_SUPABASE_URL`: Your Supabase project URL
>
> In `.env` file:
> ```env
> VITE_APPLE_CLIENT_ID=com.mycompany.myapp.web
> VITE_SUPABASE_URL=https://your-project-ref.supabase.co
> ```
>
> **If not set, you'll see console warnings**: `⚠️ Set VITE_APPLE_CLIENT_ID in .env to enable Apple Sign In`
```typescript
import { supabase } from '@/integrations/supabase/client';
// ============================================
// CONFIGURATION - Set these in your .env file
// ============================================
const APPLE_CLIENT_ID = import.meta.env.VITE_APPLE_CLIENT_ID || '';
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || '';
// Warn if not configured
if (!APPLE_CLIENT_ID) {
console.warn('⚠️ Set `VITE_APPLE_CLIENT_ID` in .env to enable Apple Sign In');
}
if (!SUPABASE_URL) {
console.warn('⚠️ Set `VITE_SUPABASE_URL` in .env to enable Apple Sign In');
}
// ============================================
/**
* Get the Apple OAuth redirect URL
* Apple will POST to our edge function, which then redirects to frontend
*/
function getRedirectUri(): string {
return `${SUPABASE_URL}/functions/v1/auth-apple-callback`;
}
/**
* Start Apple Sign In flow (redirect-based)
*
* Uses response_mode: form_post because Apple requires this when
* requesting name or email scopes. Apple will POST the response
* to our edge function.
*/
export function startAppleSignIn(isNative: boolean = false, deeplinkScheme: string = 'myapp'): void {
if (!APPLE_CLIENT_ID || !SUPABASE_URL) {
console.error('Apple Sign In not configured. Set VITE_APPLE_CLIENT_ID and VITE_SUPABASE_URL.');
return;
}
// Generate state - for native, include deeplink scheme
const state = isNative
? `${crypto.randomUUID()}|${deeplinkScheme}`
: crypto.randomUUID();
const params = new URLSearchParams({
client_id: APPLE_CLIENT_ID,
response_type: 'code id_token',
response_mode: 'form_post', // Required for name/email scopes
scope: 'name email',
redirect_uri: getRedirectUri(),
state: state,
});
const appleAuthUrl = `https://appleid.apple.com/auth/authorize?${params.toString()}`;
if (isNative) {
// Native: use Despia to open ASWebAuthenticationSession
// This import should be at the top of your LoginButton component
// despia(`oauth://?url=${encodeURIComponent(appleAuthUrl)}`);
window.location.href = appleAuthUrl; // Fallback - actual native uses despia()
} else {
// Web: redirect to Apple
window.location.href = appleAuthUrl;
}
}
/**
* Get the Apple OAuth URL (for native Despia flow)
*/
export function getAppleOAuthUrl(isNative: boolean = false, deeplinkScheme: string = 'myapp'): string {
if (!APPLE_CLIENT_ID || !SUPABASE_URL) {
console.error('Apple Sign In not configured');
return '';
}
const state = isNative
? `${crypto.randomUUID()}|${deeplinkScheme}`
: crypto.randomUUID();
const params = new URLSearchParams({
client_id: APPLE_CLIENT_ID,
response_type: 'code id_token',
response_mode: 'form_post',
scope: 'name email',
redirect_uri: getRedirectUri(),
state: state,
});
return `https://appleid.apple.com/auth/authorize?${params.toString()}`;
}
/**
* Set Supabase session from tokens received in URL
*/
export async function setAppleSession(
accessToken: string,
refreshToken: string
): Promise<boolean> {
try {
const { error } = await supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken,
});
if (error) {
console.error('Failed to set session:', error);
return false;
}
return true;
} catch (err) {
console.error('Error setting session:', err);
return false;
}
}
```
---
## Step 8: Create Login Button
File: `src/components/LoginButton.tsx`
```typescript
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import despia from 'despia-native';
import { startAppleSignIn, getAppleOAuthUrl } from '@/lib/apple-auth';
interface LoginButtonProps {
onError?: (error: string) => void;
deeplinkScheme?: string; // Your app's deeplink scheme for native
}
const LoginButton = ({ onError, deeplinkScheme = 'myapp' }: LoginButtonProps) => {
const [isLoading, setIsLoading] = useState(false);
// Detect if running in Despia native app
const isNative = typeof navigator !== 'undefined' &&
navigator.userAgent.toLowerCase().includes('despia');
const handleAppleLogin = () => {
setIsLoading(true);
try {
if (isNative) {
// NATIVE FLOW
// Use Despia to open ASWebAuthenticationSession
const oauthUrl = getAppleOAuthUrl(true, deeplinkScheme);
if (!oauthUrl) {
onError?.('Apple Sign In not configured');
setIsLoading(false);
return;
}
// oauth:// prefix tells Despia to use ASWebAuthenticationSession
despia(`oauth://?url=${encodeURIComponent(oauthUrl)}`);
// Note: Loading state will persist until user returns or cancels
} else {
// WEB FLOW
// Simple redirect to Apple - they POST back to our edge function
startAppleSignIn(false);
// Page will redirect, loading state doesn't matter
}
} catch (err) {
console.error('Login error:', err);
onError?.(err instanceof Error ? err.message : 'Login failed');
setIsLoading(false);
}
};
return (
<Button
onClick={handleAppleLogin}
disabled={isLoading}
className="w-full bg-black hover:bg-gray-800 text-white"
>
{isLoading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Connecting...
</span>
) : (
<span className="flex items-center gap-2">
{/* Apple Icon */}
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
</svg>
Sign in with Apple
</span>
)}
</Button>
);
};
export default LoginButton;
```
---
## Step 9: Create Auth Page
File: `src/pages/Auth.tsx`
This page:
1. Receives tokens from URL query params (from edge function redirect)
2. Sets the Supabase session
3. Redirects to home or shows login button
```typescript
import { useEffect, useState, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';
import { Button } from '@/components/ui/button';
import LoginButton from '@/components/LoginButton';
import { setAppleSession } from '@/lib/apple-auth';
const Auth = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const [showLogin, setShowLogin] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const hasRun = useRef(false);
useEffect(() => {
if (hasRun.current) return;
hasRun.current = true;
const handleAuth = async () => {
// Check if already logged in
const { data: { session: existingSession } } = await supabase.auth.getSession();
if (existingSession) {
navigate('/', { replace: true });
return;
}
// Check for error from edge function redirect
const errorParam = searchParams.get('error');
if (errorParam) {
setError(decodeURIComponent(errorParam));
return;
}
// Check for tokens from edge function redirect
const accessToken = searchParams.get('access_token');
const refreshToken = searchParams.get('refresh_token');
if (accessToken && refreshToken) {
setIsProcessing(true);
const success = await setAppleSession(accessToken, refreshToken);
if (success) {
// Clear tokens from URL for security
window.history.replaceState({}, '', '/auth');
navigate('/', { replace: true });
} else {
setError('Failed to set session');
setIsProcessing(false);
}
return;
}
// No tokens - show login button
setShowLogin(true);
};
handleAuth();
}, [searchParams, navigate]);
if (error) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-sm space-y-4">
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-center">
<h2 className="text-lg font-semibold text-destructive">Sign in failed</h2>
<p className="mt-2 text-sm text-muted-foreground">{error}</p>
</div>
<Button onClick={() => { setError(null); setShowLogin(true); }} className="w-full">
Try again
</Button>
<Button
variant="outline"
onClick={() => navigate('/', { replace: true })}
className="w-full"
>
Go to home
</Button>
</div>
</div>
);
}
if (showLogin) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-sm space-y-6 text-center">
<div>
<h1 className="text-2xl font-bold">Welcome</h1>
<p className="mt-2 text-muted-foreground">Sign in to continue</p>
</div>
<LoginButton onError={setError} />
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="text-center">
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="mt-4 text-sm text-muted-foreground">
{isProcessing ? 'Completing sign in...' : 'Loading...'}
</p>
</div>
</div>
);
};
export default Auth;
```
---
## Step 10: Add Routes to App.tsx
File: `src/App.tsx`
```typescript
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Index from './pages/Index';
import Auth from './pages/Auth';
import NotFound from './pages/NotFound';
import SpaRedirector from './components/SpaRedirector';
function App() {
return (
<BrowserRouter>
<SpaRedirector />
<Routes>
<Route path="/" element={<Index />} />
<Route path="/auth" element={<Auth />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
export default App;
```
---
## Step 11: Create SPA Fallback Files
File: `src/components/SpaRedirector.tsx`
```typescript
import { useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
export default function SpaRedirector() {
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
const params = new URLSearchParams(location.search);
const redirect = params.get("redirect");
if (!redirect) return;
try {
const decoded = decodeURIComponent(redirect);
// Preserve hash for Apple's fragment response
navigate(`${decoded}${location.hash ?? ""}`, { replace: true });
} catch {
console.error('Failed to decode redirect path');
}
}, [location.search, location.hash, navigate]);
return null;
}
```
File: `public/404.html`
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Redirecting</title>
</head>
<body>
<script>
(function () {
// Preserve the full path including query params AND hash (for Apple's fragment response)
var redirect = window.location.pathname + window.location.search;
var hash = window.location.hash || '';
var target = "/?redirect=" + encodeURIComponent(redirect) + hash;
window.location.replace(target);
})();
</script>
</body>
</html>
```
File: `public/_redirects`
```
/* /index.html 200
```
---
## Step 12: Configuration Summary
> ⚠️ **CRITICAL: Set Environment Variables**
>
> If you see console warnings like:
> - `⚠️ Set VITE_APPLE_CLIENT_ID in .env to enable Apple Sign In`
> - `⚠️ Set VITE_SUPABASE_URL in .env to enable Apple Sign In`
>
> **You MUST set these environment variables before Apple Sign In will work!**
### Frontend (.env file)
Create a `.env` file in your project root (or set in Lovable's Environment Variables):
```env
VITE_APPLE_CLIENT_ID=com.yourcompany.yourapp.web
VITE_SUPABASE_URL=https://your-project-ref.supabase.co
```
### Backend (Supabase Edge Function Secrets)
Set these in Supabase Dashboard > Project Settings > Edge Functions > Secrets:
```
APPLE_CLIENT_ID=com.yourcompany.yourapp.web
APPLE_TEAM_ID=ABCD1234EF
APPLE_KEY_ID=ABC123DEFG
APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIGT...key...\n-----END PRIVATE KEY-----
APP_URL=https://your-app.lovable.app
```
---
## Step 13: Using Login State in Your App
File: `src/pages/Index.tsx` (example)
```typescript
import { useEffect, useState } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { User } from '@supabase/supabase-js';
import LoginButton from '@/components/LoginButton';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
const Index = () => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null);
setLoading(false);
});
// Listen for auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
setUser(session?.user ?? null);
}
);
return () => subscription.unsubscribe();
}, []);
const handleSignOut = async () => {
await supabase.auth.signOut();
};
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
);
}
if (!user) {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-sm space-y-6 text-center">
<h1 className="text-2xl font-bold">Welcome</h1>
<p className="text-muted-foreground">Sign in with Apple to continue</p>
<LoginButton />
</div>
</div>
);
}
// User is logged in
const metadata = user.user_metadata;
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-sm space-y-6 text-center">
<Avatar className="mx-auto h-20 w-20">
<AvatarFallback>
{metadata.display_name?.charAt(0) || metadata.first_name?.charAt(0) || 'U'}
</AvatarFallback>
</Avatar>
<div>
<h1 className="text-2xl font-bold">
{metadata.display_name || metadata.full_name || 'Apple User'}
</h1>
{metadata.is_private_email && (
<p className="text-xs text-muted-foreground mt-1">
Using private email relay
</p>
)}
</div>
<Button onClick={handleSignOut} variant="outline" className="w-full">
Sign Out
</Button>
</div>
</div>
);
};
export default Index;
```
---
## Configuration Checklist
### Apple Developer Portal
- [ ] Created App ID with Sign In with Apple capability
- [ ] Created Service ID for web authentication
- [ ] Configured Service ID with:
- Domain: `your-app.lovable.app` and `your-project-ref.supabase.co`
- Return URL: `https://your-project-ref.supabase.co/functions/v1/auth-apple-callback`
- [ ] Created Sign In with Apple Key
- [ ] Downloaded `.p8` key file (one-time download!)
- [ ] Noted Team ID, Service ID, and Key ID
### Frontend Configuration (.env file)
- [ ] Set `VITE_APPLE_CLIENT_ID` in .env to your Service ID
- [ ] Set `VITE_SUPABASE_URL` in .env to your Supabase project URL
### Supabase
- [ ] Added edge function secrets:
- `APPLE_CLIENT_ID`
- `APPLE_TEAM_ID`
- `APPLE_KEY_ID`
- `APPLE_PRIVATE_KEY`
- `APP_URL`
- [ ] Deployed `auth-apple-callback` edge function
- [ ] Set `verify_jwt = false` in config.toml
### Despia (for native apps)
- [ ] Set deeplink scheme (e.g., `myapp`) in LoginButton.tsx
### App Code
- [ ] All files created and added to project
- [ ] Routes added to `App.tsx`
---
## Update Checklist
> 🚨 **MANDATORY: These values MUST be set or Apple Sign In will not work!**
Set these environment variables:
| Location | Variable | Value | If Not Set |
|----------|----------|-------|------------|
| .env file | `VITE_APPLE_CLIENT_ID` | Your Apple Service ID | ⚠️ Console warning, auth fails |
| .env file | `VITE_SUPABASE_URL` | Your Supabase project URL | ⚠️ Console warning, auth fails |
| Supabase secrets | `APP_URL` | Your frontend app URL | ❌ Wrong redirect after auth |
| LoginButton.tsx | `deeplinkScheme` prop | Your Despia deeplink scheme | ❌ Native login fails |
---
## How It All Connects
```
WEB FLOW:
LoginButton -> window.location.href = Apple OAuth URL
-> User authenticates at Apple
-> Apple POSTs to edge function (form_post)
-> Edge function verifies token, creates session
-> Edge function redirects to /auth?access_token=xxx
-> Auth.tsx calls setSession() -> Logged in
NATIVE FLOW:
LoginButton -> despia('oauth://?url=...')
-> ASWebAuthenticationSession opens -> Apple native auth
-> Apple POSTs to edge function
-> myapp://oauth/auth?tokens (deeplink closes browser)
-> /auth?tokens -> setSession() -> Logged in
```
---
## Key Differences from other OAuth Flows
| Aspect | Others | Apple |
|--------|--------|-------|
| **SDK** | Manual OAuth URL | Manual OAuth URL (redirect) |
| **Response mode** | Query params / fragment | form_post (required for name/email) |
| **Token type** | Authorization code | Identity token (JWT) |
| **Verification** | Exchange code for token | Verify JWT with Apple's public keys |
| **User info** | Fetch from API | Decoded from token + first-time user object |
| **Email** | Always provided | User can hide (private relay) |
| **Name** | From API | Only on FIRST authorization |
| **Callback** | Frontend receives params | Edge function receives POST |
---
## Troubleshooting
| Problem | Solution |
|---------|----------|
| **⚠️ Console warning about VITE_APPLE_CLIENT_ID** | Set `VITE_APPLE_CLIENT_ID` in your `.env` file with your Apple Service ID |
| **⚠️ Console warning about VITE_SUPABASE_URL** | Set `VITE_SUPABASE_URL` in your `.env` file with your Supabase URL |
| "Invalid client_id" | Check `VITE_APPLE_CLIENT_ID` matches your Service ID exactly |
| "Invalid redirect_uri" | Add exact edge function URL to Apple Developer Portal Service ID config |
| "Token verification failed" | Check token hasn't expired (5 min lifetime) |
| "bad_jwt" error | You're using manual JWT - use magic link + verifyOtp instead |
| User name not showing | Apple only sends name on FIRST authorization - name is stored in user_metadata |
| Email is `xxx@privaterelay.appleid.com` | User chose to hide email - this is expected behavior |
| Native browser doesn't close | Use `myapp://oauth/...` format (oauth/ prefix required) |
| Wrong redirect after auth | Check `APP_URL` secret is set correctly in Supabase |
---
## Security Considerations
1. **Token Verification**: Apple identity tokens are verified against Apple's public keys (JWKS)
2. **Audience Validation**: Token audience must match your client ID
3. **Issuer Validation**: Token issuer must be `https://appleid.apple.com`
4. **Token Expiry**: Apple tokens expire after 5 minutes - verify immediately
5. **Private Key Security**: Never expose `.p8` key contents to frontend
6. **State Parameter**: Prevents CSRF attacks in native flow
7. **form_post**: Apple POSTs to server, not to client - credentials never in browser URL
---
## Apple-Specific Notes
### Private Email Relay
When users choose "Hide My Email", Apple creates a unique private relay address like `abc123@privaterelay.appleid.com`. This email:
- Is unique per user per app
- Forwards to user's real email
- Can receive emails from your app
- User can disable forwarding anytime
### User Info Availability
Apple only provides user info (name, email) on the **first** authorization. After that:
- `id_token` always contains: `sub` (user ID), `email` (if shared)
- `user` object is null on subsequent logins
- Store user info on first login!
### Token Refresh
Apple doesn't provide refresh tokens in the same way as other OAuth providers. The identity token is short-lived (5 min) and only used for initial authentication. After that, you use Supabase's session management.
---
## Files Summary
| File | Purpose |
|------|---------|
| .env | Frontend environment variables (VITE_APPLE_CLIENT_ID, VITE_SUPABASE_URL) |
| src/vite-env.d.ts | TypeScript types for environment variables |
| supabase/functions/auth-apple-callback/index.ts | Edge function - receives Apple POST, redirects to frontend |
| supabase/config.toml | Edge function configuration |
| src/lib/apple-auth.ts | Client-side auth helpers |
| src/components/LoginButton.tsx | Login button - redirects to Apple |
| src/pages/Auth.tsx | Receives tokens from URL, sets session |
| src/App.tsx | Route configuration |
| src/components/SpaRedirector.tsx | Handle 404 fallback |
| public/404.html | Preserve params on 404 |
| public/_redirects | SPA routing |
Setup Requirements
-
Create an Apple Sign In application in Apple Developer Portal
-
Configure your Supabase project with Apple credentials (custom provider - requires edge function)
-
Set up your Despia project with a deeplink scheme
Important: This works with Lovable + Supabase projects but requires a custom edge function since Apple Sign In uses response_mode: form_post which requires server-side handling.
Understanding the Complete OAuth Flow
The Problem
When you wrap a Lovable web app as a native iOS/Android app using Despia:
-
The app runs in a WebView (embedded browser)
-
WebViews cannot handle OAuth redirects properly
-
Apple OAuth redirects back to a URL, but the WebView doesn't know what to do
-
Apple requires
response_mode: form_postwhen requesting name/email scopes
The Solution
For both web and native apps, we:
-
Redirect user to Apple's authorization page
-
Apple POSTs credentials directly to our edge function (form_post)
-
Edge function verifies the identity token and creates a Supabase session
-
Edge function redirects to frontend with tokens (or deeplink for native)
-
Frontend sets the session
How Native OAuth Works
Step 1: Detect if running in native app
When the user clicks "Sign in", your app first checks if it's running inside a Despia native app. Despia automatically adds despia to the userAgent string:
const isNative = navigator.userAgent.toLowerCase().includes('despia');
Step 2: Generate Apple OAuth URL
For Apple, we generate the OAuth URL on the frontend. The client ID (Service ID) is public. We use response_mode: form_post which Apple requires for name/email scopes:
// src/lib/apple-auth.ts
const APPLE_CLIENT_ID = import.meta.env.VITE_APPLE_CLIENT_ID;
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
// Redirect URI points to edge function (Apple POSTs here)
const redirectUri = `${SUPABASE_URL}/functions/v1/auth-apple-callback`;
// For native, include deeplink scheme in state
const state = isNative
? `${crypto.randomUUID()}|${deeplinkScheme}`
: crypto.randomUUID();
const params = new URLSearchParams({
client_id: APPLE_CLIENT_ID,
response_type: 'code id_token',
response_mode: 'form_post', // Required for name/email scopes
scope: 'name email',
redirect_uri: redirectUri,
state: state,
});
const oauthUrl = `https://appleid.apple.com/auth/authorize?${params}`;
Step 3: Open OAuth in secure browser session
Once you have the OAuth URL, open it using despia('oauth://?url=...'). The oauth:// prefix tells Despia to open the URL in a secure native browser session:
despia(`oauth://?url=${encodeURIComponent(oauthUrl)}`);
This opens the URL in:
-
iOS: ASWebAuthenticationSession (secure Safari sheet)
-
Android: Chrome Custom Tabs (secure Chrome overlay)
Step 4: User completes OAuth in the browser session
-
User sees Apple Sign In inside ASWebAuthenticationSession / Chrome Custom Tab
-
User authenticates with Face ID / Touch ID / password
-
Apple POSTs
id_token,code, anduserdata to your edge function -
This happens server-side (not in the browser session)
Step 5: Edge function processes and redirects
Apple uses form_post which sends an identity token (JWT) directly.
The edge function:
-
Parses form data from Apple's POST
-
Verifies the identity token using Apple's public keys (JWKS)
-
Creates or finds the Supabase user
-
Generates a real Supabase session via magic link + OTP verification
-
Redirects to frontend with tokens (or deeplink for native)
// Edge function detects native flow from state parameter
if (state?.includes('|')) {
const deeplinkScheme = state.split('|')[1];
// Redirect to deeplink to close browser session
return Response.redirect(`${deeplinkScheme}://oauth/auth?access_token=${token}`);
}
// Web flow: redirect to frontend
return Response.redirect(`${APP_URL}/auth?access_token=${token}`);
Step 6: Close browser session and return to app via deeplink
For native, the edge function redirects to a deeplink which closes the browser session:
myapp://oauth/auth?access_token=xxx&refresh_token=yyy
Deeplink format: {scheme}://oauth/{path}?params
-
myapp://- Your app's deeplink scheme -
oauth/- Required prefix - tells native code to close the browser session -
{path}- Where to navigate in your app (e.g.,auth) -
?params- Query params passed to that page
Step 7: App receives deeplink, navigates, sets session
-
Native app intercepts
myapp://oauth/...deeplink -
Closes ASWebAuthenticationSession / Chrome Custom Tab
-
Navigates WebView to
/{path}?params(e.g.,/auth?access_token=xxx) -
Your
Auth.tsxparses tokens and sets the session:
const accessToken = searchParams.get('access_token');
const refreshToken = searchParams.get('refresh_token');
await supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken || '',
});
// User is now logged in!
navigate('/');
How The Two Flows Work
Web Browser Flow
Native App Flow (Despia WebView)
Installation
Install the Despia package from NPM:
npm install despia-native
Usage
Import the SDK:
import despia from 'despia-native';
Opening the Native OAuth Session:
// Opens OAuth URL in secure native browser session
despia(`oauth://?url=${encodeURIComponent(oauthUrl)}`);
Closing happens automatically when the edge function redirects to your deeplink. The oauth/ prefix in the deeplink tells Despia to close the browser session.
Apple Developer Portal Setup
1. Create an App ID
-
Go to Identifiers
-
Create new App ID with Sign In with Apple capability
-
Bundle ID:
com.yourcompany.yourapp
2. Create a Service ID (for web)
-
Create new Services ID
-
Identifier:
com.yourcompany.yourapp.web -
Configure Sign In with Apple:
-
Domains:
your-app.lovable.app,your-project.supabase.co -
Return URL:
https://your-project.supabase.co/functions/v1/auth-apple-callback
-
3. Create a Sign In Key
-
Go to Keys
-
Create key with Sign In with Apple
-
Download the
.p8file (one-time download!) -
Note the Key ID
4. Note Your Credentials
-
Team ID: Top right of Apple Developer portal
-
Service ID:
com.yourcompany.yourapp.web -
Key ID: From the key you created
-
Private Key: Contents of
.p8file
Environment Variables
Frontend (.env)
VITE_APPLE_CLIENT_ID=com.yourcompany.yourapp.web
VITE_SUPABASE_URL=https://your-project.supabase.co
Supabase Edge Function Secrets
APPLE_CLIENT_ID=com.yourcompany.yourapp.web
APPLE_TEAM_ID=ABCD1234EF
APPLE_KEY_ID=ABC123DEFG
APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIGT...key...\n-----END PRIVATE KEY-----
APP_URL=https://your-app.lovable.app
Key Technical Notes
Why form_post?
Apple requires response_mode: form_post when requesting name or email scopes. This means:
-
Apple POSTs credentials to your server (not browser redirect)
-
Your edge function must handle the POST and redirect to frontend
-
Credentials never appear in browser URL (more secure)
User Info Only on First Auth
Apple only sends user info (name, email) on the first authorization. After that:
-
id_tokencontains:sub(user ID),email(if shared) -
userobject is null on subsequent logins -
Store user info on first login!
Private Email Relay
When users choose "Hide My Email", Apple creates abc123@privaterelay.appleid.com. This:
-
Is unique per user per app
-
Forwards to user's real email
-
User can disable forwarding anytime
Resources
For additional support or questions, please contact our support team at support@despia.com