iOS Native Sign-In Plan
Plan for Apple + Google sign-in on the iOS build, written against the Better Auth spike's decision record (#4376). Applies D2 (sub convention), D3 (account-linking recommendation, pending ratification), and D4 (deployment shape) to the iOS-specific surface. No code lands with this document — it is the design that #4379/#4380-style acceptance work will implement once the Apple prerequisite (see Prerequisite / Long Pole) is cleared.
Note
Why Sign in with Apple is not optional here
App Store Review Guideline 4.8 ("Login Services") requires: any app that uses a third-party or social login service (Google Sign-In, Facebook Login, etc.) to set up or authenticate a user's primary account must also offer an equivalent login option that (a) limits data collection to name and email, (b) lets the user keep their email private, and (c) does not collect app interactions for advertising without consent. Sign in with Apple is the only login service Apple accepts as satisfying all three — it is a review gate, not a style preference.
If zudo-text's iOS build adds Google as proposed by D3/D8 of #4376, Apple must ship in the same release Google does, not as a follow-up. Neither provider is enabled in the current email/password production configuration. The reason retrofitting is expensive: Apple's sub (the account.accountId Better Auth stores) is a stable per-user, per-Services-ID/App-ID identifier that has no relationship to a Google sub. If Apple is added after Google is already live, every existing Google-only user who later authenticates with Apple gets a second, unlinked identity (a second users row per the identity model below) unless they explicitly run the account-linking flow — which most users never do proactively. Shipping both from day one means every iOS user's first sign-in already picks (or is capable of picking) the eventual linking outcome, instead of retrofitting a merge story onto an existing user base. This is the same "second identity per user" risk called out in #4378.
Server topology: native idToken, no browser round-trip
D4 mounts Better Auth inside sync-server's existing Hono app at /, built per request via createBetterAuth(env) (Cloudflare Workers bindings only exist in request scope). On desktop, sign-in goes through a system-browser + one-time-token deep-link handoff (D5) because the OS has no native provider SDK to call. iOS does not need that indirection — both providers expose a native SDK that produces a signed identity token on-device, which the app then hands directly to Better Auth's REST API:
Apple —
ASAuthorizationController(AuthenticationServices) runs the native Sign in with Apple sheet and returns anidentityToken(a JWT signed by Apple) plus, only on the user's first authorization ever,emailandfullName.Google — the Google Sign-In SDK for iOS runs the native account picker and returns an
idToken(a JWT signed by Google) andaccessToken.
Both feed the same Better Auth client call:
await authClient.signIn.social({
provider: "apple", // or "google"
idToken: {
token: identityToken, // the provider-issued idToken/identityToken
nonce: rawNonce, // required — see "Nonce binding" below
accessToken, // Google only
user: {
// Apple only, first authorization only — see "Account linking" below
name: { firstName, lastName },
email,
},
},
});This is a direct API call from the app to sync-server's / mount — no system browser, no custom-scheme deep link, no one-time-token handoff. Better Auth would verify the idToken's signature against the provider's own JWKS and, on success, mint the same Better Auth session used by the current desktop email/password flow; from there the flow rejoins the shared D5 lifecycle (GET / for the short-lived service JWT, bearer session in the keychain, 7-day sliding session as the refresh credential).
Nonce binding. Without a nonce, an intercepted or replayed provider idToken stays valid and usable for the rest of its lifetime. Better Auth only enforces nonce validation when idToken.nonce is supplied, so it is not optional here: the app must generate a random nonce, hand its SHA-256 hash to the native request (ASAuthorizationAppleIDRequest.nonce for Apple; the Google Sign-In SDK's own nonce parameter), and send the raw, unhashed value as idToken.nonce in the signIn.social call. Apple's identity token carries the hashed value in its own nonce claim; Better Auth's Apple verifier accepts both the raw (desktop OAuth) and SHA-256-hashed (native iOS) forms, so the raw-nonce-in, hashed-nonce-in-token pairing resolves correctly on the server side.
Config requirement — appBundleIdentifier. For the desktop Apple flow, the provider clientId is the Services ID (used in the redirect-based OAuth code exchange). Native iOS idToken verification is different: Apple issues the identity token with aud set to the app's bundle ID, not the Services ID — passing only the Services ID as clientId makes Better Auth reject a native token with JWTClaimValidationFailed: unexpected "aud" claim value. The Apple provider config must therefore also set appBundleIdentifier (or use clientId: string[] / audience: string[] to accept both the Services ID and the bundle ID as valid audiences, since the same Better Auth instance serves both desktop and iOS clients). Google is simpler in comparison — pass every platform's OAuth client ID (iOS, and desktop's web client ID) as clientId: string[]; Google issues one client ID per platform within the same Cloud project, and Better Auth accepts idTokens audienced to any listed ID.
Identity model: one users row per linked human, not per provider
Per D2, the service JWT's sub is minted as `better_auth|${session.user.id}` — the Better Auth user id, never a provider-specific id. Apple and Google identities live in Better Auth's account table as rows (providerId, accountId) attached to one user row; linking or unlinking a provider adds/removes account rows and never changes user.id (D8). This is what makes the sub linking-stable: sync-server's users.sub mapping, admin/dev allow-lists, and workspace ownership are anchored to the Better Auth user id, so they survive account linking unchanged.
Concrete cross-device scenario this plan must hold up under:
A user installs the iOS app and authenticates with Apple (Guideline 4.8 requires Apple be offered; the user picks it). Better Auth creates
userrowU1with anaccountrow{providerId: "apple", accountId: "001834.xxxx"}. The service JWT'ssubisbetter_auth|U1; ausersrow keyed on that sub is created via sync-server's explicitprovisionBetterAuthUserhelper (verification itself is find-only and fails closed on unmapped subs since #4473 — this plan must wire signup to that helper).The same human later opens the desktop app and authenticates with Google, using the same underlying email Apple reported. Two sub-cases:
Auto-link fires (real, non-relay Apple email; D3's
trustedProviderscondition matches): Better Auth attaches a secondaccountrow{providerId: "google", accountId: "..."}to the sameuserrowU1— not a new user. The service JWT is stillbetter_auth|U1. This is the case D3 is designed to make automatic.Auto-link does not fire (private-relay email, or the emails otherwise don't match): Better Auth creates a second, independent
userrowU2with its ownaccountrow. The service JWT isbetter_auth|U2— a distinct sync-serverusersrow, subscription, and workspace fromU1. This is the split-identity state the "Account linking" section below addresses; it is a real intermediate state, not merely a hypothetical the plan can wave away.
Net result when auto-linking succeeds: one
usersrow, one subscription, one workspace, reachable from either provider on either device. When it does not, the twousersrows (U1/U2) stay split until the user runs the explicit linking flow described below before ever using the second provider independently — see the timing caveat in "Account linking".
Account linking: trusted providers plus an explicit fallback
D3 (recommendation, pending ratification by the repo owner) configures:
account: {
accountLinking: {
enabled: true,
trustedProviders: ["google", "apple"],
},
},with allowDifferentEmails: false and allowUnlinkingAll: false. Restricting auto-link to trustedProviders: ["google", "apple"] means Better Auth auto-links a new provider onto an existing user row when the incoming email matches an existing user's email and the provider is in the trusted list — both Apple and Google report verified emails, so this is the low-risk case described in the scenario above.
Private-relay caveat — this is why the fallback exists. Apple's "Hide My Email" feature issues @privaterelay.appleid.com addresses. These are legitimate, verified addresses — must never be rejected or treated as suspicious — but by construction they never equal a user's real Google email. A user who signs in on iOS with Apple + Hide My Email and later signs in on desktop with Google therefore does not auto-link: two different emails, two user rows, D3's auto-link condition simply doesn't fire. This is not a bug to fix in the matching logic; it is an inherent property of email-relay privacy features. The mitigation is a required product surface, not a config tweak: an explicit "Connect account" action (Better Auth's linkSocial API) in account settings, so a hide-my-email user can deliberately attach their Google account to the same identity while authenticated.
Conflict this plan must flag for ratification alongside D3. linkSocial runs through the same allowDifferentEmails gate as auto-link — with D3's allowDifferentEmails: false, an authenticated call to linkSocial({ provider: "google" }) from a Hide-My-Email Apple account fails with LINKING_DIFFERENT_EMAILS_NOT_ALLOWED, because the relay email and the real Google email are, by definition, different. So the "Connect account" surface as configured under the D3 recommendation cannot actually link the case it exists for. This is not resolvable by better UI copy; it needs one of:
Scope
allowDifferentEmails: trueto the explicit, authenticatedlinkSocialcall only (the user is already signed in and re-authenticates against the second provider in the same action — the auto-link path staysfalseand email-based takeover risk is unaffected), orA dedicated authenticated account-merge endpoint that does not route through
accountLinking.allowDifferentEmailsat all.
The former is the smaller change and consistent with D3's intent, but it is a config choice linkSocial does not currently make on a per-call basis (allowDifferentEmails is one instance-wide setting) — resolving this is in-scope spike work, not settled by this document. Flagging it here so the D3 ratification decision accounts for it.
"Connect account" only prevents the split — it does not undo one. linkSocial attaches a new account row to the Better Auth user making the call. It does not merge two already-existing, independent user rows (sessions, account rows, sync-server users row, subscription, workspace ownership) into one. Concretely: if the scenario above already reached step 2's "auto-link does not fire" branch — the user signed in independently with Google on desktop before ever running "Connect account" — U1 and U2 already both exist with their own sync-server users rows, and no linkSocial call from either session retroactively merges them. The product-level mitigation is sequencing, not recovery: the "Connect account" surface must be the way a Hide-My-Email user adds their second provider, and it must be reachable and discoverable enough that users do it before independently signing in with the other provider on a new device. A true merge tool (reassigning workspace ownership and subscription state from one users row onto another after the fact) is a separate, harder capability this plan does not design — if it is needed as a support-driven recovery path, it is out of scope here and should be tracked as its own follow-up.
Persist Apple's email/name on first authorization — there is no second chance, and no retry either. Apple returns email and fullName in the authorization response only the first time a given user authorizes this app (subsequent authorizations return user info as nil/absent, by design — Apple does not re-disclose it). Better Auth only picks this up when the client explicitly forwards it as idToken.user.{name.firstName, name.lastName, email} on the signIn.social call (see the request shape above) — it is not derivable from the identity token's claims. Once Apple has returned this payload, the authorization is consumed: if the signIn.social request that carries it fails (device offline, server error, process killed) or is never sent, that data is gone — a retried authorization sheet returns nil for user, and no later Apple sign-in from that device will recover it. The iOS implementation must therefore: (1) forward email/fullName on the very first signIn.social attempt, and (2) if that attempt can fail before the server has durably created the user row, cache the raw ASAuthorizationAppleIDCredential metadata locally (e.g. Keychain) before or alongside the network call, retry signIn.social using the cached copy rather than re-invoking the native sheet, and only clear the cached copy once account creation is confirmed to have succeeded server-side.
Apple client secret: recommend runtime-minting, not manual rotation
The Apple provider's clientSecret is not a static value — it is a JWT the app signs itself using a .p8 private key downloaded once from the Apple Developer portal (Team ID + Key ID + the key file), signed with ES256. Apple enforces a hard cap: it rejects a client-secret JWT whose exp is more than 15,777,000 seconds (~180 days / 6 months) in the future.
The native iOS idToken flow described above does not need this secret at all — idToken verification checks the token's signature and aud against Apple's JWKS directly, no client secret involved. The client secret is only needed for the desktop/browser Apple flow (D5's code-exchange path, not yet built — Apple is explicitly out of the #4380 desktop spike scope pending the membership below) and for any server-side token refresh Better Auth performs against Apple's token endpoint.
Two ways to satisfy the 6-month cap, evaluated per #4376's Q7 finding:
Manual rotation — sign a long-lived (e.g. 180-day) JWT once, store it as a Worker secret, and put a recurring calendar reminder to regenerate and redeploy it before expiry. Simple, but a missed rotation silently breaks Apple sign-in on every device using the desktop/browser path.
Runtime minting (recommended) — store only the raw inputs as Worker secrets (the
.p8key contents, Team ID, Key ID) and compute a short-lived (hours, not months) client-secret JWT inside the per-requestcreateBetterAuth(env)factory (the same factory D4 already requires for D1-bound instantiation). The 6-month constraint dissolves entirely: a JWT minted fresh on every request (or cached for a few hours and re-minted) is never close to its own expiry. This also means Apple credential rotation reduces to "the.p8key file itself", which Apple does not force-expire.
Flagged unknown, to confirm during the spike/acceptance work (carried over from #4373's Q7): Better Auth's Apple provider config accepts clientSecret as a plain string set once at configuration time; because the config object is rebuilt on every request in the per-request factory pattern, a freshly computed JWT is a normal string value each time — this should work by construction, but it has not been demonstrated in Better Auth's own docs or the spike's server prototype as of this writing. Verify by minting a short-lived secret inside createBetterAuth(env) and confirming the Apple provider accepts it, before relying on this pattern for the desktop Apple flow.
Prerequisite / long pole: the paid Apple Developer membership
None of the Apple-specific work above — a Services ID, an App ID capable of Sign in with Apple, a .p8 key, or appBundleIdentifier/native capability registration — can be provisioned without an active paid Apple Developer Program membership. Per sync-server's wrangler.toml notes (also blocking APNs, tracked separately in #2331), that membership is not yet held.
This is the sequencing constraint for the whole plan:
Not blocked on the membership: Google sign-in (native idToken flow above) and email/password — both carry the Better Auth spike's desktop and iOS acceptance work in the meantime, per #4373's Q7 recommendation ("Google + dev-login" as the spike's acceptance baseline).
Blocked on the membership: every Apple-specific step — registering the App ID / Services ID, generating the
.p8key, enabling "Sign in with Apple" capability on the iOS bundle ID, and therefore both the native iOS Apple flow and the desktop Apple code-exchange flow.
Sequencing: land and verify the Google + email/password paths first (server mount, token acceptance, iOS native Google idToken flow, desktop OTT flow). Once the paid membership is obtained, add the Apple provider config (appBundleIdentifier, runtime-minted client secret) behind the same code paths — the native idToken call shape (signIn.social({ provider: "apple", idToken })) and the linking/persistence rules above do not change based on when Apple credentials become available. Because Guideline 4.8 requires Apple to ship alongside Google on iOS (see above), the iOS app cannot go to App Store review with Google-only sign-in — the membership is the actual long pole for the iOS release, not an optional nice-to-have deferred past launch.
Summary checklist
| Area | Decision applied | Status |
|---|---|---|
| Guideline 4.8 | Apple ships alongside Google on iOS, not retrofitted | Planned (this doc) |
| Server topology | Native idToken → signIn.social({provider, idToken}), no browser hop on iOS | Planned; needs #4377/#4379 landed first |
| Apple audience | appBundleIdentifier (or clientId/audience arrays) in Apple provider config | Planned; needs paid membership |
| Google audience | clientId: string[] covering iOS + desktop client IDs | Planned |
| Identity model | sub = better_auth|<user.id>, linking-stable (D2/D8) | Locked by #4376 |
| Nonce binding | SHA-256 hash to native request, raw value as idToken.nonce | Planned; required, not optional |
| Auto-linking | trustedProviders: ["google", "apple"] (D3, pending ratification) | Recommendation carried forward |
| Private-relay fallback | Explicit "Connect account" (linkSocial) UI required | Planned; blocked on resolving the allowDifferentEmails conflict below |
allowDifferentEmails conflict | D3's instance-wide false blocks linkSocial for relay emails | Flagged for ratification; needs per-call scoping or a dedicated merge endpoint |
| Already-split accounts | linkSocial does not merge two existing user rows | Out of scope; sequencing (link before independent second sign-in) is the mitigation, not recovery |
| First-authorization capture | Forward Apple's first-response email/name via idToken.user; cache locally for retry safety | Planned; implementation constraint to enforce in iOS sign-in code |
| Apple client secret | Runtime-mint inside createBetterAuth(env); confirm Better Auth accepts a fresh string per instantiation | Recommended; unknown flagged for spike verification |
| Long pole | Paid Apple Developer membership not yet held | Blocking all Apple-specific work; Google + email/password carry the spike |