Native Authentication

Bearer sessions, email OTP, and Apple/Google ID-token sign-in for native clients.

Native clients authenticate against the same Better Auth instance the web app uses — same /api/auth/* endpoints, same user table, same sessions. The web keeps its cookie sessions untouched; native clients add a Bearer token transport, email OTP flows, and ID-token sign-in for Apple and Google. Everything is configured in src/lib/auth.ts.

What it provides

  • Bearer sessions for native clients (bearer({ requireSignature: true })), additive to the web's cookies.
  • Email OTP for native email verification, passwordless sign-in, and password reset (emailOTP plugin) — the web keeps its link-based emails.
  • Apple sign-in via native ID token, verified against your iOS bundle ID — no Services ID or Apple OAuth client secret needed.
  • Google sign-in via ID token with a web/iOS/Android audience allow-list.
  • Trusted origins for app schemes, and per-endpoint rate limits on sensitive auth routes.

Important files

FileRole
src/lib/auth.tsBetter Auth config: plugins, providers, rate limits
src/lib/auth-config/trusted-origins.tsbuildTrustedOrigins() + native-request detection
src/lib/auth-config/google-id-token.tsGoogle ID-token verifier (signature, issuer, expiry, audience)
src/server/app/shared/session.tsrequireAppSession() — maps "no session" to 401 UNAUTHENTICATED
.env.exampleAPP_SCHEME, GOOGLE_IOS_CLIENT_ID, GOOGLE_ANDROID_CLIENT_ID, APPLE_APP_BUNDLE_IDENTIFIER

Bearer sessions

The bearer plugin issues the session token in a set-auth-token response header on sign-in/sign-up. Native clients store it (iOS: Keychain) and send Authorization: Bearer <token> on every request:

POST /api/auth/sign-in/email        ← { email, password }
  200, header set-auth-token: <token.signature>

GET /api/app/v1/todos
  Authorization: Bearer <token.signature>

Key behaviors:

  • requireSignature: true — only signed tokens are accepted.
  • Sessions are database-backed and revocable: after sign-out or revoke-other-sessions, the old token stops resolving immediately.
  • Whenever a response carries a new set-auth-token, the client must overwrite its stored token.
  • Web cookie sessions are unaffected — the plugin is additive.

Better Auth's get-session returns 200 with a null body for an invalid or revoked token — not 401. The App API's requireAppSession guard converts "no session" into a uniform 401 UNAUTHENTICATED, so business endpoints behave correctly. Don't rely on /api/auth/get-session's status code for auth decisions.

Trusted origins

Better Auth rejects requests without a trusted Origin (403 MISSING_OR_NULL_ORIGIN). Native clients send their app scheme as the Origin, so it must be registered:

VariablePurpose
APP_SCHEMEYour production app scheme (e.g. soar://) — always trusted
DEV_TRUSTED_ORIGINSComma-separated extra origins, honored only in development

buildTrustedOrigins() trusts only APP_SCHEME in production; wildcard and localhost entries are development-only by construction.

Email OTP

Native apps can't click email links, so the emailOTP plugin adds code-based flows (6-digit code, 5-minute expiry, 3 attempts):

FlowEndpoints
Verify emailemail-otp/send-verification-otp (type: "email-verification") → email-otp/verify-email
Passwordless sign-inemail-otp/send-verification-otp (type: "sign-in") → sign-in/email-otp
Password resetforget-password/email-otpemail-otp/reset-password

The web keeps its link-based verification and reset emails: sendVerificationEmail skips the link email when the request comes from the app scheme (isNativeAppRequest), so native users only ever receive the OTP, and autoSignInAfterVerification signs the newly verified native user in immediately.

Apple sign-in (native-only)

Apple sign-in is registered only when APPLE_APP_BUNDLE_IDENTIFIER is set, and only for native ID tokens:

POST /api/auth/sign-in/social
{ "provider": "apple", "idToken": { "token": "<identityToken>", "nonce": "<sha256(rawNonce)>" } }
  • The token's audience must equal your iOS bundle identifier — that is the entire client configuration; no Apple Services ID or OAuth client secret.
  • Better Auth compares the request nonce against the token's nonce claim directly, and Apple stores SHA-256(rawNonce) in the claim — so the client must send the hashed nonce.
  • The web UI does not offer Apple sign-in.

Google sign-in (multi-client)

Web sign-in keeps the standard OAuth redirect flow with GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET. Native clients send an ID token instead:

POST /api/auth/sign-in/social
{ "provider": "google", "idToken": { "token": "<googleIdToken>" } }

A native Google ID token's aud is the platform client ID, so src/lib/auth-config/google-id-token.ts installs a custom verifyIdToken that checks the signature (Google JWKS), issuer, expiry, and an audience allow-list built from:

VariableAudience
GOOGLE_CLIENT_IDWeb client (also used by the redirect flow)
GOOGLE_IOS_CLIENT_IDiOS app
GOOGLE_ANDROID_CLIENT_IDAndroid app

Create all clients in one Google Cloud project so they belong to the same OAuth consent screen. account.accountLinking.enabled maps Apple, Google, and password accounts with the same email to one Better Auth user.

Rate limits

rateLimit.customRules tightens the sensitive endpoints (enabled in production): sign-in, sign-up, password reset, change-password/email, and all OTP endpoints get small per-minute budgets, and clients must not auto-retry sign-in or OTP requests on 429.

Client rules (what a native implementation must do)

  • Store the token in secure storage only (Keychain — never UserDefaults/logs).
  • Treat set-auth-token on any response as "replace the stored token".
  • On 401: clear the stored token and local state, show sign-in. No retry loops, no silent fallback to another backend.
  • Send Content-Type: application/json and a {} body even for empty POSTs.
  • Account deletion goes through DELETE /api/app/v1/account (cascade cleanup), not a Better Auth endpoint — see Account Deletion.

On this page