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:
| Half | Where it lives | What it is |
|---|---|---|
| Encryption key | expo-secure-store (Keychain / Keystore) | A fresh AES-256 key, generated per write |
| Encrypted blob | @react-native-async-storage/async-storage | The 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', }, },);| Option | Value | Why |
|---|---|---|
storage | LargeSecureStore | Above. Also holds the PKCE code verifier between starting an OAuth round trip and exchanging the code. |
persistSession | true | The session survives a cold start; the app restores it before the first frame instead of asking the user to sign in again. |
autoRefreshToken | true | The SDK refreshes the access token in the background. See the AppState note below — on React Native this needs help. |
detectSessionInUrl | false | There 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';| Status | Set when | Leaves when |
|---|---|---|
loading | Initial value, until the priming getSession() call resolves | Priming finishes; status is then derived from the session |
unauthenticated | No session | A session arrives from any flow |
needs-mfa | A session exists and checkRequiresMfa(session) is true | The MFA challenge succeeds and the refreshed session reaches AAL2 |
authenticated | A session exists and no MFA step is outstanding | The 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_RECOVERYsets it totrue. BothverifyOtp({ type: 'recovery' })and a tapped recovery link emit that event.USER_UPDATEDclears it — that is the event the successful password change fires.SIGNED_INclears it, but only once priming has completed. This guard is load-bearing: auth-js replaysSIGNED_INfor a session restored from storage, before the priming effect resolves, so an ungated branch would wipe the flag on every cold start.SIGNED_OUTclears 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
isRecoveringonly 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();| Member | Type | Notes |
|---|---|---|
status | AuthStatus | As above |
session | Session | null | The live Supabase session; session.user.id is the identity every query keys off |
isRecovering | boolean | Recovery 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:
| Flow | What the app does |
|---|---|
| Sign-up confirmation | verifyOtp({ type: 'signup' }) with the typed code |
| Password reset | verifyOtp({ 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.
Printing {{ .Token }} turns a URL-only secret into a guessable 6-digit one. otp_expiry sizes the brute-force window, token_verifications rate-limits the guessing, and otp_length must be 6 — the app's code entry is a fixed six-box input, so an 8-digit code silently cannot be entered.
These are shared web/native backend policy, so the values are yours to choose. docs/native/config-requirements.mdoc has the defaults, the reasoning and the recommended posture.
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.
| Flow | Screen | Hook(s) |
|---|---|---|
| Sign in with password | apps/native/app/(auth)/sign-in.tsx | use-sign-in-with-password.ts |
| Sign up | apps/native/app/(auth)/sign-up.tsx, features/auth/components/sign-up-form.tsx | use-sign-up-with-password.ts |
| Confirm email | features/auth/components/confirm-email-step.tsx | use-verify-email-otp.ts, use-resend-email-otp.ts, use-sign-in-with-password.ts |
| Reset: request | apps/native/app/(auth)/reset-password.tsx | use-request-password-reset.ts |
| Reset: verify code | apps/native/app/(auth)/verify-reset-code.tsx | use-verify-email-otp.ts, use-request-password-reset.ts |
| Reset: new password | apps/native/app/update-password.tsx, features/auth/components/new-password-form.tsx | features/accounts/hooks/mutations/use-update-password.ts |
| MFA challenge | apps/native/app/(auth)/mfa-challenge.tsx | use-verify-mfa-challenge.ts, features/accounts/hooks/queries/use-mfa-factors.ts |
| OAuth | features/auth/components/oauth-providers.tsx | use-sign-in-with-oauth.ts |
| Sign in with Apple | features/auth/components/apple-sign-in-button.tsx | use-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:
reset-password.tsx— collects the email, callsresetPasswordForEmail, pushes to the code screen with the address as a param.verify-reset-code.tsx—useVerifyEmailOtp('recovery'). On success it does nothing:PASSWORD_RECOVERYflipsisRecoveringand the guard redirects. Resend re-requests a reset (recovery has noauth.resendequivalent), gated by a 60-second cooldown fromuse-resend-cooldown.tsthat matches GoTrue's per-addressmax_frequency. Reaching this screen without an email param redirects back to step 1.update-password.tsx→NewPasswordForm—updateUser({ password }). On successUSER_UPDATEDclearsisRecoveringand the guard sends the user home; a toast confirms, because the rootToasteroutlives 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 digit — OtpInput'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:
signInWithOAuth({ provider, options: { redirectTo, skipBrowserRedirect: true, scopes } })returns a URL instead of navigating.WebBrowser.openAuthSessionAsyncopens it in the system browser (ASWebAuthenticationSession / Custom Tabs) and waits for themkkit://auth/callbackredirect.preferEphemeralSession: falseis web parity — a web OAuth redirect shares the browser's existing provider session.- The returned URL's params are read explicitly, because Supabase and Google report failures as
?error=…&error_description=…andgetQueryParamsonly surfaces a param literally namederrorCode. exchangeCodeForSession(code)completes PKCE; the verifier is read back out ofLargeSecureStoreautomatically.
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:
Enable and configure the GitHub provider on your Supabase project.
docs/native/config-requirements.mdoccovers the Supabase-side setup and the redirect URL allowlist.Add it to the env list — this is the only app-side change most providers need:
EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS=google,githubConfirm the logo.
features/auth/components/provider-logos/index.tsxalready registersgoogle,facebook,githubandazureagainst the reused web.webpassets inapps/native/assets/oauth/. A provider with no entry renders no logo — the button still works. For anything else, drop a.webpin that folder and add a line to theLOGOSmap, keyed by the Supabase provider id (noteazuremaps tomicrosoft.webp).If the provider needs extra scopes, add them to
OAUTH_SCOPESinfeatures/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.
Validation is client-side only. Sign-up calls auth.signUp straight from the device with no server action in between, so the zod schema is the whole gate. Anyone talking to your Supabase project directly is not bound by it. Setting the requirements server-side in GoTrue closes that, needs no client change, and surfaces as the weak_password reasons list the alert already renders.
The settings change-password screen ignores the flags. features/accounts/components/update-password-form.tsx uses PasswordUpdateSchema from @kit/accounts/shared, a flat min(8) with no policy hooks. Web's equivalent screen uses the same schema and has the same gap, so this is upstream's shape rather than a native regression — but with the flags on, that one screen accepts passwords the other two reject.
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.
| Challenge | Enrollment | |
|---|---|---|
| Screen | app/(auth)/mfa-challenge.tsx | app/(home)/(user)/(tabs)/settings/mfa-enroll.tsx |
| Component | features/auth/components/otp-code-form.tsx | features/accounts/components/mfa-enroll-flow.tsx |
| Hooks | use-verify-mfa-challenge.ts, use-requires-mfa.ts | use-enroll-mfa-factor.ts, use-verify-mfa-enrollment.ts |
| Triggered by | status === 'needs-mfa', or an insufficient_aal rejection | The 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.