Apple and Google Sign-In with Expo and Supabase
A production-minded approach to social sign-in in Expo: native Apple credentials, OAuth redirects, Supabase sessions, secure storage, and the identity edge cases that affect real users.
Social login looks like a button, but it is an identity system with several parties involved: your app, an operating system or browser, Apple or Google, and Supabase. The difficult bugs usually live at the boundaries between them: an almost-correct redirect URL, a token stored in the wrong place, or two accounts that look like the same person.
This guide focuses on the decisions that keep an Expo + Supabase implementation maintainable. It deliberately avoids tying the process to a particular dashboard layout, because provider consoles change much faster than the underlying flow.
An app bundle is not a secure place for a provider client secret, an Apple signing key, a Supabase service-role key, or any other privileged credential. Values prefixed with EXPO_PUBLIC_ are included in the client bundle. A Supabase project URL and publishable/anon key may be client-side; authority belongs in Supabase policies and server code.
Mobile OAuth Is Not Web OAuth in a Smaller Window
On the web, a provider normally returns the user to an HTTPS callback on the same site. A native app needs the browser to return control to the installed app. That return path is a deep link such as myapp://auth/callback, declared by the app and allow-listed by the services involved.
The browser is still valuable: it gives the provider an isolated, familiar sign-in surface and avoids placing a password form in your app. But it creates two requirements that web-only code can hide:
- A redirect URI must exactly match the environment that initiated login.
- The app must be able to consume the result after the browser hands control back.
For generic OAuth flows, Expo recommends expo-auth-session and AuthSession.makeRedirectUri(). Use a development build for realistic native redirect testing; Expo Go cannot use a custom scheme for generic OAuth redirects. Native Sign in with Apple is a separate case and is supported in Expo Go on iOS, but a development build is still the useful integration target before release.
Choose the Correct Flow Per Platform
There is not one universal implementation to force everywhere.
| Surface | Good default |
|---|---|
| iOS Apple sign-in | Native Apple authentication, then give the Apple identity token to Supabase |
| Android/iOS Google sign-in | Provider OAuth in a system browser through Supabase, or a native Google credential flow that Supabase can verify |
| Web | Supabase OAuth redirect to an HTTPS callback |
The important architectural point is that Supabase should own the resulting application session. Google and Apple prove an identity; Supabase validates that assertion, associates it with a user/identity record, and issues the access and refresh tokens your app uses with Row Level Security.
Configuration Boundaries
Configure an explicit app scheme in Expo config. Choose a stable, reverse-domain-like value early; changing it means rebuilding native apps and updating redirect registrations.
{
"expo": {
"scheme": "com.example.product",
"ios": {
"bundleIdentifier": "com.example.product",
"usesAppleSignIn": true
},
"plugins": ["expo-apple-authentication"]
}
}For Supabase, register only redirects your product actually uses: production HTTPS web callbacks, development web origins, and the native scheme callback. Treat the allow-list as a security boundary, not a convenience wildcard.
Google configuration commonly needs distinct OAuth client IDs for web, iOS, and Android. Their identifiers are not interchangeable: iOS is tied to a bundle ID, Android to a package name and signing certificate, and web to authorized browser origins/redirect URIs. Apple likewise distinguishes native app IDs from web-oriented Services IDs. Keep those values in environment-specific configuration, never in conditional logic scattered across screens.
Document the exact redirect URI produced by each build profile and compare it character-for-character with the provider and Supabase configuration. Scheme, path, slash count, casing, and environment are all significant.
Native Apple Sign-In
On Apple platforms, native authentication is usually the cleanest user experience. Request a cryptographic nonce, pass its hash to Apple, then give the original nonce and returned identity token to Supabase. The nonce binds the token to the request and helps prevent replay or token-substitution problems.
import * as AppleAuthentication from "expo-apple-authentication";
import { supabase } from "./supabase";
export async function signInWithApple(rawNonce: string, hashedNonce: string) {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
nonce: hashedNonce,
});
if (!credential.identityToken) throw new Error("Apple did not return an identity token.");
const { data, error } = await supabase.auth.signInWithIdToken({
provider: "apple",
token: credential.identityToken,
nonce: rawNonce,
});
if (error) throw error;
// Apple only supplies a name on the initial authorization. Persist it now,
// if it exists, rather than assuming it can be retrieved later.
if (credential.fullName?.givenName || credential.fullName?.familyName) {
await supabase.auth.updateUser({
data: {
given_name: credential.fullName.givenName,
family_name: credential.fullName.familyName,
},
});
}
return data.session;
}Apple may give a private relay address rather than the person's normal email. It is still a valid contact address, but it is not a durable assumption that it equals a Google email or an existing account. Apple also returns the name only on the first authorization in normal circumstances, so capture it then; do not make a later profile screen depend on it being available.
Browser OAuth and Redirect Completion
For a browser OAuth flow, ask Supabase for the provider URL and open it with the redirect URI your native app owns. The callback is then exchanged for a session using the code from that link. Exact APIs vary with the Supabase client version and flow chosen, but the lifecycle should remain explicit.
import * as Linking from "expo-linking";
import * as WebBrowser from "expo-web-browser";
import { supabase } from "./supabase";
WebBrowser.maybeCompleteAuthSession();
export async function signInWithGoogle() {
const redirectTo = Linking.createURL("auth/callback");
const { data, error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: { redirectTo, skipBrowserRedirect: true },
});
if (error || !data.url) throw error ?? new Error("Missing OAuth URL.");
const result = await WebBrowser.openAuthSessionAsync(data.url, redirectTo);
if (result.type !== "success") return null; // cancelled or dismissed is not a crash
const code = new URL(result.url).searchParams.get("code");
if (!code) throw new Error("OAuth callback did not contain a code.");
const { data: session, error: exchangeError } =
await supabase.auth.exchangeCodeForSession(code);
if (exchangeError) throw exchangeError;
return session.session;
}Use state and PKCE as provided by the supported library/flow; do not replace them with a hand-rolled random query parameter. Avoid legacy implicit token flows: authorization code plus PKCE reduces token leakage and does not require a client secret in the app.
The most common redirect failures are mundane: registering a web URL but sending a custom scheme, using an Expo development URL in a production build, forgetting to rebuild after changing the scheme, or allowing a redirect in Google but not in Supabase. Test every actual build profile, not just one simulator run.
Sessions, Restoration, and Sign-Out
Initialize the Supabase client with a storage adapter appropriate to the platform. On native devices, session material should use encrypted storage such as expo-secure-store, not AsyncStorage. Let the SDK restore and refresh a valid session, and subscribe to auth changes so the UI is derived from one source of truth.
supabase.auth.onAuthStateChange((_event, session) => {
setAuthState({ ready: true, session });
});
export async function signOut() {
const { error } = await supabase.auth.signOut();
if (error) throw error;
// Clear app-owned user cache after the session is gone.
}Signing out of your app is not necessarily signing the person out of Google or Apple in the system browser. That is normally desirable: a user can return without re-entering credentials. It does mean the app must clearly distinguish local app sign-out from deleting an account. Deletion needs a server-authorized process that removes or anonymizes data according to your policy; deleting local tokens is not account deletion.
Identity Linking Is a Product Decision
Do not silently merge accounts merely because provider emails appear equal. Apple relay addresses, unverified email claims, changed email addresses, and a user who created one account with Google and another with Apple all make email a poor global identity key.
Start with one Supabase user per identity. If you offer linking, require the user to be signed in, explicitly confirm the action, and handle the provider flow as an authenticated link operation. Explain the result and have a support path for accidental duplicates. The risk is not only technical: an incorrect merge can expose one person's data to another.
Useful tests include:
- first Apple authorization and a later login with no returned name;
- Apple relay email and a Google account with a different address;
- cancelled browser flow and an expired/invalid callback;
- clean install, relaunch, token refresh, sign-out, and account deletion;
- iOS and Android real devices, plus the production redirect configuration.
What Belongs Where
- Client configuration: Supabase URL, publishable/anon key, app scheme, and public provider client IDs where a provider requires them.
- Server or provider secret store: Apple private signing keys, OAuth client secrets, Supabase service-role keys, webhook secrets, and any key that can bypass user-level authorization.
- Database policy: ownership checks and Row Level Security. A valid session is authentication; it is not permission to read every row.
For an Expo app that needs account-backed vocabulary or progress, this approach is a better foundation than storing a provider token in a component and hoping it lasts. The same production discipline applies to any future account-enabled project: keep identity, session storage, data authorization, and deletion as connected parts of one system.
Summary
The implementation is not complete when the provider button returns a name. It is complete when every build can return through a registered redirect, Supabase owns a securely stored session, your database enforces ownership, sign-out and deletion mean different things, and duplicate identities have a deliberate policy. That is what turns social sign-in from a demo feature into reliable product infrastructure.
Related Tutorials
The full path from a working Expo project to an approved App Store release: build profiles, credentials, versioning, TestFlight, and the review rejections that are easiest to avoid.
Validating every input at the edge, rate limiting without a database, and using signed state so a client can hold data it cannot forge.