Authentication in the Expo app | Expo Supabase SaaS Kit

Understand how the native app stores sessions, derives auth status, and runs every sign-in, sign-up, reset, MFA and OAuth flow — and why each one differs from web.

Native auth in apps/native talks to Supabase Auth directly with the JS SDK, keeps the session in the device keychain, and derives one status value that every route guard reads. Several things work differently from apps/web on purpose — no cookies, no confirmation links, no deep-link infrastructure — and each difference has a reason you need before you change anything here.

Session storage

Web stores the session in an httpOnly cookie that the browser attaches to every request. A native app has no cookie jar, so apps/native/features/core/supabase.ts hands Supabase its own storage adapter: LargeSecureStore.

The adapter exists because expo-secure-store is backed by the iOS Keychain, whose items are practically limited to about 2 KB — smaller than a Supabase session, which carries a JWT plus a refresh token. Instead of storing the session there, LargeSecureStore splits it:

HalfWhere it livesWhat it is
Encryption keyexpo-secure-store (Keychain / Keystore)A fresh AES-256 key, generated per write
Encrypted blob@react-native-async-storage/async-storageThe session, AES-CTR encrypted with that key

setItem generates a new 32-byte key with crypto.getRandomValues, encrypts the value with aes-js in CTR mode, writes the key to SecureStore under the same key name, and writes the hex blob to AsyncStorage. getItem reverses it. removeItem deletes both halves.

The two halves can desync — an OS-level keychain reset, a restore from backup, a partially completed write. getItem treats any failure as exactly that and self-heals:

async getItem(key: string) {
try {
const encrypted = await AsyncStorage.getItem(key);
if (!encrypted) return encrypted;
return await this.decrypt(key, encrypted);
} catch (err) {
// Storage halves got out of sync
console.warn('[LargeSecureStore] getItem failed — clearing both halves', {
key,
err,
});
await this.removeItem(key);
return null;
}
}

Clearing both halves signs the user out. That is the correct outcome: a blob whose key is gone is unrecoverable, and leaving it behind would fail the same way on every subsequent launch.

Client options

The client is created once, at module scope, and every hook in the app imports that single instance:

export const supabase = createClient<Database>(
resolveDevHost(supabaseUrl),
supabasePublicKey,
{
auth: {
storage: new LargeSecureStore(),
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
flowType: 'pkce',
},
},
);
OptionValueWhy
storageLargeSecureStoreAbove. Also holds the PKCE code verifier between starting an OAuth round trip and exchanging the code.
persistSessiontrueThe session survives a cold start; the app restores it before the first frame instead of asking the user to sign in again.
autoRefreshTokentrueThe SDK refreshes the access token in the background. See the AppState note below — on React Native this needs help.
detectSessionInUrlfalseThere is no page URL to parse. The app never receives a session through a URL fragment; leaving this on would be dead code at best.
flowType'pkce'Required by the OAuth flow: the browser returns a code, and exchangeCodeForSession trades it plus the stored verifier for a session. The implicit flow would put tokens in a redirect URL, which is exactly what native must avoid.

resolveDevHost rewrites loopback hosts for the Android emulator in dev only; it is covered in docs/native/running-locally.mdoc.

Supabase JS has no notion of an app being backgrounded, so useAuthState ties refresh to AppState: startAutoRefresh() when the app becomes active, stopAutoRefresh() otherwise. Without this the refresh timer keeps firing while the app is suspended and the session can be stale on resume.

The missing environment variables throw at import time with an explicit message rather than failing later inside an auth call. EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_PUBLIC_KEY are covered in docs/native/config-requirements.mdoc.

The auth state machine

apps/native/features/auth/hooks/use-auth-state.ts owns the Supabase subscription for the whole app. apps/native/lib/auth/auth-context.tsx wraps it in a provider — mounted in app/_layout.tsx inside ReactQueryProvider — and exposes it as useAuth().

Status values

export type AuthStatus =
| 'loading'
| 'authenticated'
| 'needs-mfa'
| 'unauthenticated';
StatusSet whenLeaves when
loadingInitial value, until the priming getSession() call resolvesPriming finishes; status is then derived from the session
unauthenticatedNo sessionA session arrives from any flow
needs-mfaA session exists and checkRequiresMfa(session) is trueThe MFA challenge succeeds and the refreshed session reaches AAL2
authenticatedA session exists and no MFA step is outstandingThe session is cleared, or the user switches

checkRequiresMfa lives in apps/native/features/auth/hooks/use-requires-mfa.ts. It calls supabase.auth.mfa.getAuthenticatorAssuranceLevel() and returns true only when nextLevel === 'aal2' and nextLevel !== currentLevel. It fails open — a transient error resolves to authenticated rather than trapping the user on a challenge screen. That is safe because the real enforcement is server-side, on the /api/v1/* handlers (see docs/native/data-fetching.mdoc); the client check is UX.

Status is derived in an effect that runs after React commits, not inside the auth event callback. Deriving it inline would call back into supabase.auth while its internal lock is still held.

Two side effects worth knowing about

useAuthState also clears the React Query cache in two places: on SIGNED_OUT, and when the session's user id changes from one non-null value to another. The second guards account switching — one user's cached workspace, members and invitations must never render under another user's screens.

isRecovering

Password recovery is a mode, not a status: the user has a real session, but the only thing they may do with it is set a new password. useAuth() therefore returns isRecovering alongside status.

  • PASSWORD_RECOVERY sets it to true. Both verifyOtp({ type: 'recovery' }) and a tapped recovery link emit that event.
  • USER_UPDATED clears it — that is the event the successful password change fires.
  • SIGNED_IN clears it, but only once priming has completed. This guard is load-bearing: auth-js replays SIGNED_IN for a session restored from storage, before the priming effect resolves, so an ungated branch would wipe the flag on every cold start.
  • SIGNED_OUT clears it.

Because a user leaving the app to read the emailed code is routine, and the OS may kill the app while they are gone, the flag is also persisted. apps/native/features/auth/lib/recovery-marker.ts writes the recovering user's id to AsyncStorage under makerkit.auth.recovering:

  • It is not stored in LargeSecureStore — a user id is not a credential.
  • On cold start the priming effect reads it and restores isRecovering only when the marker matches the restored session's user id. A marker for a different user is dropped, which makes the store self-healing.
  • Every write is fire-and-forget from an auth event handler, so failures only console.warn. A read failure is treated as absent: failing open loses the shell block, whereas signing the user out would abandon a flow they are midway through.

The flag is seeded before initialized flips, so no guard ever observes a session without its recovery flag.

The useAuth() API

const { status, session, isRecovering, signOut } = useAuth();
MemberTypeNotes
statusAuthStatusAs above
sessionSession | nullThe live Supabase session; session.user.id is the identity every query keys off
isRecoveringbooleanRecovery mode, independent of status
signOut() => Promise<void>Calls supabase.auth.signOut(); the SIGNED_OUT handler clears the query cache and the recovery marker

useAuth throws if called outside <AuthProvider>, so a missing provider fails loudly rather than rendering a signed-out shell.

useAuth is one of the two sanctioned exceptions to the import rule that keeps features/ from importing app-level code — see docs/native/project-structure.mdoc.

How the guards consume it

app/(auth)/_layout.tsx, app/(home)/_layout.tsx and app/update-password.tsx each read useAuth() and branch on status plus isRecovering, redirecting with expo-router's <Redirect>. update-password deliberately sits outside both route groups, because a guard that redirects from a layout unmounts the whole group and this screen has to survive a mid-flow session change. The guard logic itself, the route lists in features/core/config/routes.config.ts, and what happens on each branch are documented in docs/native/routing-navigation.mdoc.

Why every email flow uses a typed code

This is the single most important thing to understand before you change an auth flow.

When your mail client opens a confirmation link, it opens it in a browser. That browser shares no cookie jar, no storage and no process with your app. On web that is fine — the link lands on your own origin, the server sets the cookie, and the session is established in the same browser the user will keep using. On native there is nowhere for the resulting session to go: the app cannot see it.

So the kit does not try. Every email-driven flow prints a 6-digit code alongside the link, and the app asks the user to type it:

FlowWhat the app does
Sign-up confirmationverifyOtp({ type: 'signup' }) with the typed code
Password resetverifyOtp({ type: 'recovery' }) with the typed code
Sensitive operations (account deletion)POST /api/v1/otp/send, then a typed code

The consequence is deliberate and large: the app needs no deep-link or Universal Links infrastructure on the auth critical path. No associatedDomains, no intentFilters, no hosted .well-known files, no Apple Team ID and Android release SHA-256 just to let a user confirm their email. Those are listed as not implemented in docs/native/covered-features.mdoc, and auth does not block on them.

Team invitations solve the same problem a different way: they are not typed codes. The invited user signs in, and the app lists the invitations that match their verified email (GET /api/v1/me/invitations), accepting by invitation id. The invite_token never reaches the client — the server re-resolves it and matches the row against the JWT email. That flow is covered in docs/native/workspaces.mdoc.

Also note what native does not send. signUp passes no emailRedirectTo (GoTrue falls back to SITE_URL, so the emailed link still confirms on web exactly as a web sign-up does) and resetPasswordForEmail passes no redirectTo (the reset template hard-codes its next target and never interpolates {{ .RedirectTo }}, so passing one changes nothing). Neither sends a captchaToken: native carries no Turnstile token, which is why the global GoTrue captcha toggle must stay off — it would break every native auth call.

The flows

Every flow ends the same way: the session lands, onAuthStateChange fires, status recomputes, and the layout guard navigates. No flow navigates on success itself — doing so would race the guard.

FlowScreenHook(s)
Sign in with passwordapps/native/app/(auth)/sign-in.tsxuse-sign-in-with-password.ts
Sign upapps/native/app/(auth)/sign-up.tsx, features/auth/components/sign-up-form.tsxuse-sign-up-with-password.ts
Confirm emailfeatures/auth/components/confirm-email-step.tsxuse-verify-email-otp.ts, use-resend-email-otp.ts, use-sign-in-with-password.ts
Reset: requestapps/native/app/(auth)/reset-password.tsxuse-request-password-reset.ts
Reset: verify codeapps/native/app/(auth)/verify-reset-code.tsxuse-verify-email-otp.ts, use-request-password-reset.ts
Reset: new passwordapps/native/app/update-password.tsx, features/auth/components/new-password-form.tsxfeatures/accounts/hooks/mutations/use-update-password.ts
MFA challengeapps/native/app/(auth)/mfa-challenge.tsxuse-verify-mfa-challenge.ts, features/accounts/hooks/queries/use-mfa-factors.ts
OAuthfeatures/auth/components/oauth-providers.tsxuse-sign-in-with-oauth.ts
Sign in with Applefeatures/auth/components/apple-sign-in-button.tsxuse-sign-in-with-apple.ts

All hook paths are relative to apps/native/features/auth/hooks/mutations/ unless stated.

Sign in with password

Gated on authConfig.providers.password. The form uses PasswordSignInSchema from @kit/auth/shared — the same schema web uses — with react-hook-form and @kit/mobile-ui/form (see docs/native/forms.mdoc).

One case is handled specially. An unconfirmed account is otherwise a dead end, so a email_not_confirmed API error swaps the screen to ConfirmEmailStep with the submitted credentials rather than showing an error. GoTrue checks the password before returning that code, so reaching the code step already required the right one.

Sign up and the confirm-email step

sign-up.tsx holds both steps in a single route as screen state, not as two routes, and calls usePreventRemove so a swipe or hardware back returns to the filled form instead of popping out to sign-in. The submitted credentials are kept so Back restores what was typed.

The code step needs the password, not only the email: tapping the emailed link confirms on web and spends the token, and the only way back into the app afterwards is to sign in with those credentials. That is what the "Already confirmed elsewhere? Sign in instead" button does — it appears once a code or a resend has failed, which are the two ways that case surfaces.

If email confirmations are disabled on your project, signUp returns a session outright and the code step is skipped entirely.

useSignUpWithPassword also converts Supabase's silent duplicate-signup response (an empty identities array) into a thrown User already registered, which the error mapper renders.

Password reset

Three steps, three files:

  1. reset-password.tsx — collects the email, calls resetPasswordForEmail, pushes to the code screen with the address as a param.
  2. verify-reset-code.tsxuseVerifyEmailOtp('recovery'). On success it does nothing: PASSWORD_RECOVERY flips isRecovering and the guard redirects. Resend re-requests a reset (recovery has no auth.resend equivalent), gated by a 60-second cooldown from use-resend-cooldown.ts that matches GoTrue's per-address max_frequency. Reaching this screen without an email param redirects back to step 1.
  3. update-password.tsxNewPasswordFormupdateUser({ password }). On success USER_UPDATED clears isRecovering and the guard sends the user home; a toast confirms, because the root Toaster outlives the unmount.

NewPasswordForm handles one required error path rather than a defensive one: GoTrue demands AAL2 for a password change once MFA is enrolled, so an insufficient_aal error routes to /mfa-challenge?next=/update-password. That next value is validated against the MFA_NEXT_ROUTES allowlist before the challenge screen navigates back.

MFA challenge

mfa-challenge.tsx loads the user's TOTP factors, auto-selects when there is exactly one, and otherwise shows a picker. Entry uses the shared OtpCodeForm, which auto-submits on the sixth digitOtpInput's onComplete fires as soon as six digits are present, and the form suppresses the duplicate submit when the button and onComplete land in the same frame. There is no resend slot, because a TOTP code is generated on the device rather than mailed.

useVerifyMfaChallenge calls mfa.challengeAndVerify (one call, matching web's container), then the screen calls refreshSession() so the AAL claims realign before anything reads them. A factor-fetch error signs the user out, mirroring web.

OAuth

features/auth/lib/oauth.ts runs the whole round trip:

  1. signInWithOAuth({ provider, options: { redirectTo, skipBrowserRedirect: true, scopes } }) returns a URL instead of navigating.
  2. WebBrowser.openAuthSessionAsync opens it in the system browser (ASWebAuthenticationSession / Custom Tabs) and waits for the mkkit://auth/callback redirect. preferEphemeralSession: false is web parity — a web OAuth redirect shares the browser's existing provider session.
  3. The returned URL's params are read explicitly, because Supabase and Google report failures as ?error=…&error_description=… and getQueryParams only surfaces a param literally named errorCode.
  4. exchangeCodeForSession(code) completes PKCE; the verifier is read back out of LargeSecureStore automatically.

A user cancel resolves to 'cancelled' and shows no error. A concurrent open is absorbed the same way, on both platforms — iOS returns a locked result, while Android's JS polyfill throws an already open error instead.

OAuth is provider-generic

Adding a provider is configuration plus an asset. There is no per-provider code path.

To add GitHub:

  1. Enable and configure the GitHub provider on your Supabase project. docs/native/config-requirements.mdoc covers the Supabase-side setup and the redirect URL allowlist.

  2. Add it to the env list — this is the only app-side change most providers need:

    EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS=google,github
  3. Confirm the logo. features/auth/components/provider-logos/index.tsx already registers google, facebook, github and azure against the reused web .webp assets in apps/native/assets/oauth/. A provider with no entry renders no logo — the button still works. For anything else, drop a .webp in that folder and add a line to the LOGOS map, keyed by the Supabase provider id (note azure maps to microsoft.webp).

  4. If the provider needs extra scopes, add them to OAUTH_SCOPES in features/auth/components/oauth-providers.tsx, which mirrors web's map.

EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS is parsed by features/auth/config/auth.config.ts — a comma-separated list, trimmed, defaulting to google. The button label is generated from the provider id (getProviderName capitalises it and strips a trailing .com), so no new strings are needed either.

Remember that every EXPO_PUBLIC_ value is baked into the bundle at build time: changing this list needs a new build to reach an installed app.

Sign in with Apple

Apple is the one provider that does not use the browser flow. features/auth/lib/apple.ts runs the native expo-apple-authentication sheet and calls signInWithIdToken with the credential's identity token, passing the SHA-256 hex nonce to Apple and the raw nonce to Supabase. It returns the credential's fullName, which is non-null only on the first authorization, so oauth-providers.tsx writes the account name once and never clobbers it. The button is Apple's own AppleAuthenticationButton because HIG and App Store guideline 4.8 require it, and it is rendered first and at least as prominently as Google.

It is iOS-only and off by default. expo-apple-authentication stays a dependency and Expo autolinks its config plugin during prebuild, so apps/native/app.config.ts strips the com.apple.developer.applesignin entitlement unless EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS contains apple — with the entitlement present, even simulator builds require a paid Apple signing team. On Android the provider is filtered out of the rendered list entirely; on iOS it is additionally gated on AppleAuthentication.isAvailableAsync().

Turning it on is a build-and-account procedure, not a flag flip. docs/native/config-requirements.mdoc has the Apple Developer prerequisites and the App Store 4.8 / 5.1.1(v) obligations; docs/native/building-shipping.mdoc has the prebuild and signing steps.

Error handling

Supabase errors reach the user through two files.

features/auth/lib/auth-error-mapper.ts turns an error into an i18n key, in this order:

export function mapAuthError(error: unknown): string {
if (!error) return FALLBACK_KEY;
if (error instanceof Error) {
if ('code' in error && typeof error.code === 'string' && error.code) {
return `errors.${error.code}`;
}
if (error.message) return `errors.${error.message}`;
}
if (typeof error === 'string' && error) {
return `errors.${error}`;
}
return FALLBACK_KEY;
}

The message fallback is why the catalogue carries keys like "Invalid login credentials" alongside invalid_credentials — older GoTrue responses have no code.

features/auth/components/auth-error-alert.tsx renders the result under the auth. namespace, with auth.errors.default as the Trans default, so an unmapped code degrades to a generic message rather than printing a raw key. It special-cases one thing: a server-side password policy rejection (weak_password) arrives with a reasons[] array, which is rendered as a bulleted list. That is the only way a GoTrue-side policy reaches the client.

To add a case, add the message under errors.<supabase_code> in apps/native/lib/i18n/additions/en/auth.json (a key web does not have) or apps/native/lib/i18n/overrides/en/auth.json (a key web has, deliberately worded differently, with a required why), then run pnpm i18n:native. The app already ships one of each: mfa_verification_failed as an addition, and otp_expired as an override — because native's codes are typed, not clicked. Never hand-edit the generated catalogue; see docs/native/i18n.mdoc.

Note the pattern every auth screen uses around React Query: the mutation's error is copied into local state, because React Query nulls error at the start of every retry and the alert should only clear on success.

Password policy

features/auth/config/password-policy.config.ts reads three flags that mirror web's set:

export const passwordRequirements: PasswordRequirements = {
specialChars:
process.env.EXPO_PUBLIC_PASSWORD_REQUIRE_SPECIAL_CHARS === 'true',
numbers: process.env.EXPO_PUBLIC_PASSWORD_REQUIRE_NUMBERS === 'true',
uppercase: process.env.EXPO_PUBLIC_PASSWORD_REQUIRE_UPPERCASE === 'true',
};

features/auth/schemas/password.schema.ts feeds them into the kit's shared factories, producing native instances with identical rules and i18n keys to web's:

export const PasswordSignUpSchema =
createPasswordSignUpSchema(passwordRequirements);
export const PasswordResetSchema =
createPasswordResetSchema(passwordRequirements);

Import from that file rather than taking the ready-made exports from @kit/auth/shared, which are bound to web's NEXT_PUBLIC_* values. The base rule is min(8).max(99); the flags add the character-class checks.

The recovery screen's NewPasswordForm does use PasswordResetSchema, so it honours the flags. docs/native/config-requirements.mdoc has the full picture, including the caveat that GoTrue's server-side password_requirements is a four-level ladder that cannot mirror three independent booleans exactly.

MFA: challenge versus enrollment

The two halves live in different features, mirroring web's split. MFA challenge is auth; MFA enrollment is accounts.

ChallengeEnrollment
Screenapp/(auth)/mfa-challenge.tsxapp/(home)/(user)/(tabs)/settings/mfa-enroll.tsx
Componentfeatures/auth/components/otp-code-form.tsxfeatures/accounts/components/mfa-enroll-flow.tsx
Hooksuse-verify-mfa-challenge.ts, use-requires-mfa.tsuse-enroll-mfa-factor.ts, use-verify-mfa-enrollment.ts
Triggered bystatus === 'needs-mfa', or an insufficient_aal rejectionThe user, from settings

Enrollment names the factor, calls mfa.enroll({ factorType: 'totp', issuer }), then verifies a code from the authenticator. Both halves call challengeAndVerify; the enrollment hook additionally refreshes the session on success, so the new AAL claims do not bounce the user to the sign-in challenge mid-session.

There is no QR code. Enrollment opens the otpauth:// URI with Linking.openURL, which hands it straight to the authenticator app, and shows the secret in a monospace, selectable block with a copy button as fallback. A QR code would need a camera permission and a scanner dependency to solve a problem that does not exist here: the authenticator is usually on the same device the user is enrolling from, so there is no second screen to point a camera at. docs/native/covered-features.mdoc records this as a rejected approach.

Dismissing enrollment mid-flow leaves an unverified factor behind. It stays deletable from the factors list.