Auth
How the mobile app signs users in - providers, universal links, passkeys, the app lock, and deletion.
The app signs in against the same Better Auth instance as the website, over EXPO_PUBLIC_API_URL. Its session lives in expo-secure-store, namespaced by config.mobile.slug, so two apps never share one.
Sign-in methods
| Method | Notes |
|---|---|
| Email + password | Same rules and limits as the web. |
| Magic link | Opens through the app's deep-link scheme. |
| Social providers | One button per entry in config.auth.socialProviders, in that order, each with its brand logo. Every provider takes the browser flow the web uses. |
Adds the native sheet where the two EXPO_PUBLIC_GOOGLE_* ids are set. On iOS the build also registers the reversed iOS client id as a URL scheme, which app.config.ts derives from EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID at build time. | |
| Apple | Adds the native sheet on iOS, shown whenever the server serves the native lane (GET /auth/apple/availability) - no rebuild needed. Native-only mode needs APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY and APPLE_APP_BUNDLE_IDENTIFIER; the web button also needs APPLE_CLIENT_ID and apple in socialProviders - see Social OAuth. |
| Passkeys | Platform biometrics - see Two-factor and passkeys. |
| Two-factor | TOTP and backup codes. |
App Store rule: an iOS app offering any third-party sign-in must offer Sign in with Apple beside it. On iOS the app shows its social buttons only while the server reports the native Apple lane, and then adds Sign in with Apple beside them; until it has asked, it falls back to whether apple is in socialProviders.
The Apple client secret the provider holds (Service ID, or the bundle identifier in native-only mode) is minted once per process and lasts 180 days. Redeploy at least twice a year, or Apple sign-ins begin failing.
What the backend adds
A project without the app gets none: the plugin is stripped, the rest read config.mobile.enabled.
- The
expo()plugin and the app scheme intrustedOrigins. React Native sends noOrigin, so Better Auth matches theexpo-originheader. - A captcha exemption for that scheme. React Native cannot render Turnstile, and the exemption is only as strong as a header, so the rate limiter still bounds guessing.
POST /auth/apple/link-code. Stores the Apple refresh token a native sign-in produces, so deletion revokes it.GET /auth/apple/availability. Public; answers{ native, web }so the app knows whether to offer Sign in with Apple.
Universal links
Two web pages open in the app rather than the browser.
| Path | Payload |
|---|---|
/accept-invitation/<id> | The invitation id, in a path segment |
/delete-account?token= | The verification token, in the query |
Three association files claim those paths and the passkey party. All 404 without the app.
| File | Served by | Claims |
|---|---|---|
/.well-known/apple-app-site-association | Your site | iOS universal links (applinks) and passkeys (webcredentials) |
/.well-known/assetlinks.json | Your site | Android app links, from MOBILE_ANDROID_SHA256_FINGERPRINTS |
The same Apple path, webcredentials alone | Your backend | The passkey rpID host on separate |
Android verifies app links only when assetlinks.json carries the installed build's signing key, read from eas credentials. On separate the passkey rpID is the API host, which serves no such file, so Android cannot associate the app for passkeys: serve it there too, or keep the rpID on the web host.
App lock
config.mobile.appLock.enabled ships a biometric gate the user turns on in Settings, with a delay of immediately, one minute or five. It relocks on every cold start and is a device gate, never a second factor: it re-opens a session and grants nothing.
Deleting an account
/delete-account?token= in the app when installed, in the browser otherwise.An App Store or Google Play subscription cannot be cancelled from the server, so the Settings copy tells the user to cancel it in their store settings. A sole owner of a shared organization is refused until another member is the owner.
The token is spent when checked, before the owner is compared: a link opened on the wrong account is refused and consumed.
Rate limits behind carrier NAT
Better Auth keys auth limits by IP, and a carrier puts thousands of subscribers behind one. Raise AUTH_RATE_LIMIT_MULTIPLIER and set TRUSTED_PROXY so the backend limits the real client - see Environment variables.