GenerateSaaS

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

MethodNotes
Email + passwordSame rules and limits as the web.
Magic linkOpens through the app's deep-link scheme.
Social providersOne button per entry in config.auth.socialProviders, in that order, each with its brand logo. Every provider takes the browser flow the web uses.
GoogleAdds 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.
AppleAdds 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.
PasskeysPlatform biometrics - see Two-factor and passkeys.
Two-factorTOTP 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 in trustedOrigins. React Native sends no Origin, so Better Auth matches the expo-origin header.
  • 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.

Two web pages open in the app rather than the browser.

PathPayload
/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.

FileServed byClaims
/.well-known/apple-app-site-associationYour siteiOS universal links (applinks) and passkeys (webcredentials)
/.well-known/assetlinks.jsonYour siteAndroid app links, from MOBILE_ANDROID_SHA256_FINGERPRINTS
The same Apple path, webcredentials aloneYour backendThe 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

Deleting from Settings mails a verification link and keeps the session. It lasts 24 hours.
The link opens /delete-account?token= in the app when installed, in the browser otherwise.
Confirming cancels any web subscription, deletes the account and its data, signs the user out everywhere, revokes any Apple grant, and notifies your admins - the deletion flow every platform shares.

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.

On this page