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 (
emailOTPplugin) — 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
| File | Role |
|---|---|
src/lib/auth.ts | Better Auth config: plugins, providers, rate limits |
src/lib/auth-config/trusted-origins.ts | buildTrustedOrigins() + native-request detection |
src/lib/auth-config/google-id-token.ts | Google ID-token verifier (signature, issuer, expiry, audience) |
src/server/app/shared/session.ts | requireAppSession() — maps "no session" to 401 UNAUTHENTICATED |
.env.example | APP_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-outorrevoke-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:
| Variable | Purpose |
|---|---|
APP_SCHEME | Your production app scheme (e.g. soar://) — always trusted |
DEV_TRUSTED_ORIGINS | Comma-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):
| Flow | Endpoints |
|---|---|
| Verify email | email-otp/send-verification-otp (type: "email-verification") → email-otp/verify-email |
| Passwordless sign-in | email-otp/send-verification-otp (type: "sign-in") → sign-in/email-otp |
| Password reset | forget-password/email-otp → email-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:
| Variable | Audience |
|---|---|
GOOGLE_CLIENT_ID | Web client (also used by the redirect flow) |
GOOGLE_IOS_CLIENT_ID | iOS app |
GOOGLE_ANDROID_CLIENT_ID | Android 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-tokenon 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/jsonand 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.
