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

*Canonical: https://makerkit.dev/docs/react-native-supabase/authentication*

---

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:

```ts
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:

```ts
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

```ts
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_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

```ts
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`.

{% alert type="warning" title="Typed codes make three backend settings load-bearing" %}
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.
{% /alert %}

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

{% img src="/images/docs/native-sign-in.webp" width="402" height="874" alt="The native sign-in screen: email and password fields, a forgot-password link, and a Google OAuth button below the divider" /%}

The buttons under the divider are rendered from `EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS` rather than hardcoded — see [OAuth is provider-generic](#oauth-is-provider-generic). The divider itself is `OAuthProviders`' `hasFormAbove` prop, fed from the same `authConfig.providers.password` flag that gates the form: turn the flag off and both the form and the "Or continue with" rule disappear, leaving the provider buttons alone on the screen.

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.

{% img src="/images/docs/native-sign-up.webp" width="402" height="874" alt="The native sign-up screen: email, password and repeat-password fields above a Sign up with Email button" /%}

This is step one. Submitting it swaps the same route to the six-digit code step rather than pushing a new screen, which is why Back restores the filled form rather than returning to sign-in.

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.tsx` — `useVerifyEmailOtp('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.tsx` → `NewPasswordForm` — `updateUser({ 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 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:

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:

   ```bash
   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:

```ts
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:

```ts
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:

```ts
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.

{% alert type="warning" title="Two honest caveats" %}
**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.
{% /alert %}

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.
