Skip to content

Clerk Frontend API gotchas on native Capacitor

Token extraction: Authorization header, not response body

Applies to: Clerk Frontend API calls via @capacitor/http (native HTTP layer, no browser context)

When calling POST /v1/client or other Frontend API endpoints from native Capacitor code (via @capacitor/http, not fetch()), Clerk returns the rotating client token in the Authorization response header as a raw JWT, not in the response body.

This is different from browser behavior, where the token may appear in response.body.client.token or other nested paths.

Critical: Never rely on Step 1 (POST /v1/client returning 200) as proof the token is usable. The token must be exercised end-to-end through the full OAuth flow — signIn.create(), Custom Tab OAuth completion, deep-link callback, and handshake exchange — before declaring the integration successful. A 200 status is necessary but not sufficient.

Body format: application/x-www-form-urlencoded is required

Applies to: Frontend API mutation endpoints (/v1/client/sign_ins, /v1/client/handshake, etc.)

These endpoints require application/x-www-form-urlencoded request bodies, not JSON. If you send a JSON body, the endpoint silently ignores it and returns a 200 with an incomplete or empty response object.

Example (wrong):

typescript
// This will silently fail — JSON body is ignored
const res = await fetch(`${CLERK_API_BASE}/v1/client/sign_ins`, {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${clientToken}`,
    'Content-Type': 'application/json'  // WRONG
  },
  body: JSON.stringify({
    strategy: 'oauth_google',
    redirect_url: 'https://app.objectuve.com/sso-callback'
  })
})

Example (correct):

typescript
// Correct: use application/x-www-form-urlencoded
const params = new URLSearchParams()
params.append('strategy', 'oauth_google')
params.append('redirect_url', 'https://app.objectuve.com/sso-callback')

const res = await HttpClient.post(`${CLERK_API_BASE}/v1/client/sign_ins`, {
  headers: {
    'Authorization': `Bearer ${clientToken}`,
    'Content-Type': 'application/x-www-form-urlencoded'
  },
  data: params.toString()
})

Refer to the Frontend API reference at https://clerk.com/docs/reference/frontend-api for the exact parameter names and encoding for each endpoint.

A retry-exhausted 5xx and a terminal 4xx throw the same error shape

Applies to: any new caller of clerkFrontendApi.ts's clerkFetch() (directly or via a service method built on it) that needs to branch on why a call failed.

clerkFetch()'s single throw site (clerkFrontendApi.ts:294-315) throws the identical ClerkApiError shape for a terminal 4xx and for a 5xx that already exhausted MAX_RETRIES — the two are not distinguishable by catching a different error class, only by reading a property on the one you got. status >= 500 && retries > 0 recurses instead of throwing (the retry guard immediately above the throw site, clerkFrontendApi.ts:283-285), so control only reaches the throw with a 5xx once the retry budget is spent — err.retryExhausted = response.status >= 500 (clerkFrontendApi.ts:304) records that redundantly rather than making a caller re-derive it from status alone.

isTerminalClerkError(err) (clerkFrontendApi.ts:130-134) is the discriminator — true only for 400 <= status < 500. It's the only correct way to tell "Clerk evaluated this request and rejected it" (terminal, fail closed) apart from "Clerk, or the network, failed before evaluation was possible" (transient, fail open). A network-level failure (clerkFetch's outer try/catch rethrows unmodified, clerkFrontendApi.ts:276-281) carries no status at all and also reads as non-terminal — the same fail-open branch has to handle both a used-up 5xx and a connection that never reached Clerk.

Three call sites currently branch on this:

  • useClerkNative.init() clears a stale session on a 4xx but stays signed in through a 5xx (useClerkNative.ts:51-83).
  • useClerkNative.signInWithPassword() throws a separate, structured ClerkSignInIncompleteError for a non-complete 200 response — a different failure shape entirely, not an HTTP error, so it never goes through isTerminalClerkError at all (useClerkNative.ts:136-182).
  • SignIn.vue's passwordLogin() catch block chains all three signals in a fixed order: incomplete-status first, then isTerminalClerkError (429 special-cased, then any other 4xx), then fail-open last (SignIn.vue:898-936).

General rule: don't add a new Clerk Frontend API caller that regexes the thrown Error's message or assumes a 4xx from string content — read err.status via isTerminalClerkError() instead. The message string's shape (`Clerk API ${method} ${path} → ${status}${suffix}`) is identical across every status band; only the discriminator on the object tells them apart. See Authentication § Native Password Sign-In Status Contract for the full three-stance contract this feeds into. (OBJ-2690, PR #2442.)

Never call window.Clerk directly on native — gating UI or mutating a session

Applies to: any auth-gated UI logic (buttons, conditional rendering) and any auth-mutating call (sign-out, sign-in, session teardown) on a view, component, or shared helper that native (Capacitor) users can reach.

Because window.Clerk never loads inside the Capacitor native WebView (see above), anything that reads or calls it — directly, or indirectly through @clerk/vue's useAuth().isSignedIn or Session.active() (ionic_frontend/src/helpers/session.js!!window.Clerk?.user) — is permanently false/unresolved on native, even when the backend correctly authenticates the user via the separate useClerkNative() bearer-token path (see "Native Architecture" in docs/architecture/authentication.md). Two distinct failure modes share this one root cause:

  • Read side: GraphQL data (e.g. allyStatus) comes back correct because it's authenticated through a different path, but a local loggedIn computed derived from the wrong signal never sees it, so auth-gated UI renders its signed-out state regardless of the real session.
  • Write side: a call like window.Clerk?.signOut() optional-chains to undefined and resolves immediately, doing nothing — no error, no rejected promise. The caller has no signal that the mutation it asked for never happened.

This has shipped three times with the same root cause — twice read-side, once write-side. First fix (OBJ-1570 / PR #1641) made PublicProfile.vue's loggedIn reactive to Clerk hydration, but only wired the web signal — a no-op on native. Second fix (OBJ-1709 / PR #1749) added a shared, native-aware source and repointed the sites onto it. Third fix (OBJ-2077 / PR #2025) found the same assumption on the write side — see below.

Read side: gating UI

Use useAuthStore().isSignedIn instead (ionic_frontend/src/composables/useAuthStore.ts) — a reactive ref that branches on isNative: useClerkNative().signedIn on native, @clerk/vue's useAuth().isSignedIn on web. This is now the single source of truth for "is this user signed in" across platforms.

Worked example — the three call sites fixed in OBJ-1709, all following the same pattern:

typescript
const { isSignedIn } = useAuthStore()
const loggedIn = computed(() => !!isSignedIn.value)
  • ionic_frontend/src/views/PublicProfile.vue:533-534
  • ionic_frontend/src/views/PublicGoal.vue:412-413
  • ionic_frontend/src/components/GoalEventCard.vue:178-179

Caveat for non-component callers: useAuth() from @clerk/vue relies on Vue's inject() and only works inside an active component's setup(). useAuthStore() is also called from apollo-client.ts's Apollo link callbacks, outside any component instance — useAuthStore.ts guards the web branch with getCurrentInstance(), falling back to a plain, non-reactive window.Clerk?.user read for those callers (which never consume isSignedIn anyway).

helpers/session.js's Session.active() shim itself was left in place — other, non-native-reachable callers may still use it — only the three flagged call sites above were repointed.

Write side: mutating the session (sign-out)

Use useAuthStore().signOut() instead (ionic_frontend/src/composables/useAuthStore.ts:105-116) — it branches on isNative the same way: useClerkNative().signOut() on native, window.Clerk?.signOut() on web. Only the native branch (ionic_frontend/src/composables/useClerkNative.ts:325-341) actually clears the session — it zeroes the in-memory clientToken/sessionId/cachedJwt, wipes secureStorage, and calls clerkFrontendApi.endSession() to end the session server-side. Calling window.Clerk?.signOut() directly, as the write-side bug did, skips all of that on native and leaves the real session — secureStorage, the in-memory mirrors, and Clerk's server-side session — fully intact behind a UI that looks signed out.

Worked example — Session.logout() (OBJ-2077): ionic_frontend/src/helpers/session.js's shared sign-out helper (logout(), lines 59-101) had exactly one Clerk teardown call, window.Clerk?.signOut(), which no-oped on native. Fixed by replacing it with a delegation to useAuthStore().signOut() (lines 97-98). Every one of Session.logout()'s three call sites inherited the original bug, because they all funnel through the shared helper:

  • ionic_frontend/src/components/SideMenu.vue:444-447
  • ionic_frontend/src/views/Settings.vue:919-921
  • ionic_frontend/src/composables/useApiError.ts:32 (the global GraphQL UNAUTHORIZED handler's auto-logout path)

Symptom: on Android, "Sign Out" navigated to /sign-in looking successful, but the native Clerk session was never torn down. The next "Continue with Google" attempt reused the still-live session and createSignIn collided with it — POST /v1/client/sign_ins → 400 (session_exists). A cold app restart re-hydrated from the same untouched secureStorage and landed back on the dashboard, still signed in.

Fix: Session.logout() now delegates its Clerk teardown to useAuthStore().signOut() instead of calling window.Clerk?.signOut() directly, so it inherits the native/web branch for free. SideMenu.vue's handler was a second, independent bug — fire-and-forget (Session.logout() was called without await before navigating) — fixed to await it, since the no-op fix alone is worthless if the call site doesn't wait for the real teardown to finish before navigating away.

If you find a new useAuth() / Session.active() / window.Clerk read gating UI, or a new direct window.Clerk write call (sign-out, sign-in, session mutation) on a path native can reach, repoint it onto useAuthStore()'s native-aware equivalent instead of calling window.Clerk yourself.

Sign in with Apple bypasses the redirect flow entirely

Applies to: native (Capacitor) OAuth — anything reading or extending the "Native Architecture" flow in docs/architecture/authentication.md.

Every native OAuth strategy documented above (oauth_google) goes through the redirect flow: Browser.open() into a Custom Tab (Android) / SFSafariViewController (iOS), deep-link callback, handshake exchange. oauth_apple does not — it's deliberately excluded from signInWithOAuth()'s type (useAuthStore.ts:69, Exclude<OAuthStrategy, 'oauth_apple'>) and routed through a separate signInWithApple() path instead.

useAuthStore.signInWithApple()useClerkNative.signInWithApple() (ionic_frontend/src/composables/useClerkNative.ts:177) imports AppleSignIn from ionic_frontend/src/plugins/appleSignIn.ts, a thin bridge to AppleSignInPlugin.swift (ionic_frontend/ios/App/App/) — a local Capacitor plugin (not @capacitor-community/apple-sign-in; that package's Package.swift pins an incompatible capacitor-swift-pm range for Capacitor 8) wrapping ASAuthorizationController. iOS presents its system sheet in-process; there is no browser and no redirect URL. The plugin hands back an Apple identity token, which createSignInWithAppleToken() exchanges with Clerk server-side via its oauth_token_apple strategy — Clerk marks a first-time identity transferable, and createSignUpWithAppleToken() promotes it into a new user.

Why the exception exists: the SFSafariViewController redirect path stranded App Store reviewers on iPad — Clerk's sso-callback is a universal link reached via a server-side redirect, and universal links don't fire on those, so appUrlOpen never fires, completeOAuthRedirect() never runs, and the sheet never closes. This was App Store rejection guideline 2.1(a), fixed in build 4.2.1 (AppleSignInPlugin.swift, PR #1834).

If you're extending native OAuth: don't assume every strategy shares one code path. Check useAuthStore.ts's Exclude<OAuthStrategy, 'oauth_apple'> type split and SignIn.vue's enabledOAuthStrategies / oauth_apple branch before generalizing anything in the redirect flow to "all OAuth."

Sign-in ↔ sign-up handoff: fall back both directions, and branch on a structured error code

Applies to: createSignInWithAppleToken() / createSignUpWithAppleToken() (ionic_frontend/src/services/clerkFrontendApi.ts) and useClerkNative.signInWithApple() (ionic_frontend/src/composables/useClerkNative.ts:177).

A first-time Apple identity has no Clerk account yet, and an existing Apple identity already has one — but signInWithApple() doesn't know which case it's in before it calls Clerk, so both directions of a 422 mismatch are expected, not exceptional:

  • Sign-in → sign-up: createSignInWithAppleToken() calling POST /v1/client/sign_ins for an identity with no account gets back a 422 (Clerk's form_identifier_not_found / external_account_not_found / user_not_found / identifier_not_found). Instead of throwing, it catches this and returns a synthetic {status: 'needs_identifier', verificationStatus: 'transferable'} result — useClerkNative.signInWithApple() reads that as "no account, promote this identity" and calls createSignUpWithAppleToken().
  • Sign-up → sign-in (the "vice versa" direction, OBJ-2180): createSignUpWithAppleToken() calling POST /v1/client/sign_ups can itself 422 with form_identifier_exists — a stale transferable state, a multi-device race, or a double-tap landing on an identity that already has an account. It catches that specific code and falls back by calling createSignInWithAppleToken() directly, rather than letting the error propagate uncaught. This fallback is one level deep only — createSignInWithAppleToken()'s own catch never calls back into sign-up — so there's no ping-pong risk even if both directions somehow fire in the same attempt.

Originally shipped sign-in→sign-up only (App Store Guideline 2.1(a) rejection: "Your app displayed an error message when we attempted to sign in using Continue with Apple button" — a new user hit the unhandled 422 and saw a visible error banner). Fixing only that direction left the reverse race reproducing the identical symptom, so both directions ship together as one fix (PR #2114).

Both catches branch on a structured err.code, not the formatted error message. clerkFetch() parses Clerk's error envelope ({ errors: [{ code, message, meta }], clerk_trace_id }) and attaches the parsed code directly onto the thrown Error as a ClerkApiError (code: string | null), in addition to folding it into the human-readable message. IDENTITY_NOT_FOUND_CODES and IDENTITY_EXISTS_CODES (both in clerkFrontendApi.ts, just above clerkFetch) are the two Sets each catch checks err.code against. Regexing the formatted message string instead would miss the code whenever extractClerkError can't parse the response body (malformed/HTML body) — the code silently disappears from the message in that case, but err.code stays reliably null rather than vanishing without a signal.

If you're touching this exchange again: useClerkNative.signInWithApple() always adopts the sign-up call's own result (result = signUpResult, unconditional) once it falls into the sign-up branch — even when sign-up doesn't fully complete — so a thrown "did not complete" error reports what sign-up actually returned, not the original sign-in's stale status.

A session_exists 400 on the in-process Apple/Google exchange means the sign-in already succeeded — recover, don't fail

Applies to: useClerkNative.signInWithApple() / signInWithGoogle() (ionic_frontend/src/composables/useClerkNative.ts) — the in-process ID-token paths. Distinct from the OBJ-2077 session_exists cause documented above under Write side: mutating the session (sign-out) — same status code, two independent producers, do not conflate a fix for one with a fix for the other.

createSignInWithAppleToken()/createSignInWithGoogleToken() (POST /v1/client/sign_ins) can return 400 { errors: [{ code: 'session_exists' }] } when a live session already exists on the client — a double-tap retry, an app-resume race, or a slow-network retry landing after the first attempt already succeeded server-side are the observed triggers. This is not a failure — Clerk is reporting the sign-in already worked. Before OBJ-2659, this propagated uncaught to useNativeOAuth.handleNativeOAuth()'s generic catch, which mapped every non-cancellation throw to ERROR_MSGS[strategy] — "Apple/Google didn't connect. Try again." — the exact opposite of what the response meant, inviting a retry that reproduced the identical 400.

Root cause vs. OBJ-2077, for anyone tempted to treat these as the same bug: OBJ-2077's session_exists came from Session.logout()'s sign-out never tearing down the native session, so the next sign-in collided with a session that should have already ended. OBJ-2659's session_exists has no teardown bug behind it — it's two concurrent/sequential sign-in attempts against a session that legitimately exists because the first attempt already completed.

Fix (shipped): both signInWithApple() and signInWithGoogle() catch the code (SESSION_EXISTS_CODES, clerkFrontendApi.ts) and call a shared recoverExistingSession(currentToken, strategy) (useClerkNative.ts) instead of letting the error propagate:

  1. GET /v1/client (clerkFrontendApi.getClient()) reloads the client. The rotated token is persisted to secureStorage and the module-level clientToken before any subsequent call — the same OBJ-2577 ordering trap (GET /v1/client rotates the token; skipping the persist-first step shipped broken on-device once already while every unit test passed).
  2. A candidate session id is resolved in order: clerkFrontendApi.lastActiveSessionIdOnClient(body) (the reloaded client's last_active_session_id) → the in-memory sessionIdsecureStorage.getSessionId(). No candidate anywhere → fail (recoveryOutcome: 'no_candidate').
  3. Liveness, not id-membership, decides whether to adopt. isSessionActiveOnClient(body, candidate) is the only check used — a session id merely appearing in client.sessions is not enough; ended/replaced/expired sessions remain in that array too (see that function's own doc comment, clerkFrontendApi.ts:440-470). true → adopt. false → fail (recoveryOutcome: 'no_active_session'). null (shape drift — sessions absent/unparseable) → adopt only when the candidate came directly from last_active_session_id (Clerk itself naming the live session is a positive signal on its own); otherwise fail.
  4. On adopt: sessionId is set, persisted to secureStorage, signedIn.value = true, and the function returns normally — signInWithApple()/signInWithGoogle()'s callers see no exception, and handleNativeOAuth reaches its existing success path (router.replace(redirectUrl)) exactly as an ordinary sign-in would.
  5. On any fail branch, throwRecoveryError() fires exactly one Sentry.captureException, tagged 'clerk.native': 'sessionExistsRecovery', 'clerk.native.recovery': <outcome>, 'oauth.strategy': 'oauth_apple' | 'oauth_google', then throws a typed ClerkSessionRecoveryError carrying recoveryOutcome as a property — not baked into the message, same ClerkApiError.code/ClerkSignInIncompleteError.signInStatus precedent as the section above. useNativeOAuth.ts's handleNativeOAuth catch branches on that property before the generic ERROR_MSGS[strategy] assignment, sets the user-facing message to "You're already signed in — close and reopen the app to continue.", and does not re-report to Sentry — the typed error already carries the tag from this step.

Naming update (OBJ-3473): ERROR_MSGS[strategy] in the paragraph above and in step 5 no longer exists under that name — the map was renamed to TERMINAL_REJECT_MSGS and now supplies copy for only one of four ordered arms (isTerminalClerkError(e) / retryExhausted / a positively-tagged transport failure / a fail-closed default) in useNativeOAuth.handleNativeOAuth()'s catch. The recovery-property check in step 5 still runs before that branch, unchanged. See docs/development/gotchas.md's "A 400 that means the operation succeeded" for the sibling naming note.

A wrongly-adopted dead session is contained, not dangerous. A dead session mints no JWT — getSessionToken() returns null and the existing global UNAUTHORIZED auto-logout (useApiError.ts:32) recovers. This is what makes the null-shape-drift branch above safe to resolve optimistically (adopt on last_active_session_id alone) rather than spending an extra network call to probe first.

Scope: in-process only, deliberately. The redirect path (signInWithOAuth()createSignIn()) can also produce session_exists, but its only known cause is the OBJ-2077 sign-out defect above, with no evidence of recurrence — this fix does not speculatively extend recovery there.

Device-verification gap: Apple was verified on-device (iPad12,1, the double-tap-after-success repro that surfaced this bug in the first place). Google's in-process recovery path is unit-test-only — device verification is blocked on OBJ-2528 Task 0.

Applies to: createSignUpFromTransfer(), createSignUpWithAppleToken(), createSignUpWithGoogleToken() (ionic_frontend/src/services/clerkFrontendApi.ts) — all three ultimately POST /v1/client/sign_ups with no legal_accepted — plus completeSignUpWithLegalConsent() (same file), the separate completion call all three now funnel into.

POST /v1/client/sign_ups with transfer: 'true' alone returns status: 'missing_requirements' — not a session — once Clerk's legal_consent_enabled environment setting is on (confirmed live against production, OBJ-2599). The transfer itself succeeds (Clerk accepts the identity), it just refuses to complete the sign-up without consent on record.

This used to be a single-shot request — an early implementation (OBJ-2599, PR #2394) threaded an optional legalAccepted param through the initial POST as a sibling key next to transfer, asking for consent unconditionally on every transferable identity. OBJ-2602's reconciliation (PR #2385, merged over #2394) replaced that with a two-step, ID-scoped completion instead, and the legalAccepted param was deleted from createSignUpFromTransfer() — keep this in mind if you ever see it referenced in an old PR diff or issue thread, it no longer exists:

typescript
// clerkFrontendApi.ts — createSignUpFromTransfer (initial POST, never carries legal_accepted)
async createSignUpFromTransfer(clientToken: string): Promise<TokenSignInResult> {
  const { body, newClientToken } = await clerkFetch('POST', '/v1/client/sign_ups', clientToken, {
    transfer: 'true',
  })
  return { ...this.parseTokenSignInBody(body), newClientToken }
},

// clerkFrontendApi.ts — completeSignUpWithLegalConsent (second call, only when Clerk actually
// reports legal_accepted as outstanding — see isLegalConsentPending() below)
async completeSignUpWithLegalConsent(clientToken: string, signUpId: string): Promise<TokenSignInResult> {
  const { body, newClientToken } = await clerkFetch('PATCH', `/v1/client/sign_ups/${signUpId}`, clientToken, {
    legal_accepted: 'true',
  })
  return { ...this.parseTokenSignInBody(body), newClientToken }
},

Like every other Frontend API mutation on this page, both calls go on the wire as application/x-www-form-urlencoded (clerkFetch's encodeForm, via URLSearchParams) — the literal string 'true', not a JSON boolean. createSignUpFromTransfer()'s request body is now byte-identical whether or not legal_consent_enabled is on — the branch only shows up in whether the second call ever fires.

Why two-step, not single-shot: it's conditional — useClerkNative.ts's isLegalConsentPending(result) only reaches the completion call when Clerk's missing_requirements/unverified_fields response actually names legal_accepted as outstanding, so an environment with legal_consent_enabled: false never shows the modal at all. The single-shot shape asked unconditionally, which meant a stray consent field on every environment regardless of whether Clerk wanted one.

Fail-closed contract: useClerkNative.ts's completeSignUpConsent() is the only caller of completeSignUpWithLegalConsent(), and it only runs when the global consent modal's accept handler invokes it — nothing defaults the flag or calls it speculatively. declineSignUpConsent(), any modal dismissal, or an app kill mid-flow all leave the sign-up parked at missing_requirements with no completion call ever sent; the pre-existing fail-closed branch stays as the backstop if Clerk itself ever ignores the field.

Design rationale — why consent is captured after the transfer, not before the OAuth browser launches: a checkbox on the sign-in card would show a terms gate to every returning user merely signing in, and would need the flag persisted across a browser round-trip the app can be backgrounded or killed during. Capturing it instead at the moment Clerk names legal_accepted as outstanding — with the rotated client token already in secureStorage — removes the persistence requirement entirely and asks for consent at the only moment it's legally meaningful: account creation. See docs/architecture/authentication.md's Native Architecture step 4 for the full mechanism.

The modal and its resolvers live in useClerkNative.ts / App.vue, not SsoCallback.vue. SsoCallback.vue owned the modal and a confirmLegalConsent resolver under OBJ-2599's design — that architecture was folded into OBJ-2602's global-overlay design (Task 2/3 of the OBJ-2602 reconciliation, PR #2385 → #2484), because the iOS in-process Apple/Google paths never load /sso-callback and a modal mounted there structurally can't reach them. SsoCallback.vue today is observe-only: a pendingConsent computed reading authStore.pendingConsentSignUpId for its own "One quick thing first." backdrop line, nothing else. The single AgreeToTermsModal is mounted globally in App.vue, which calls useClerkNative.ts's completeSignUpConsent() / declineSignUpConsent() on accept/decline.

iOS coverage (OBJ-2599 Task 3's gap, filed as OBJ-2616): resolved as part of OBJ-2602. createSignUpWithGoogleToken() / createSignUpWithAppleToken() — the in-process equivalents used by iOS's Sign in with Apple and iOS's in-process Google sign-in (useNativeOAuth.ts's GOOGLE_IN_PROCESS = platform === 'ios') — both route through createSignUpWithToken() and post transfer: 'true' with no legal_accepted, same as the transfer path. useClerkNative.ts's signInWithApple() and signInWithGoogle() now each call isLegalConsentPending() on that result and set pendingConsentSignUpId on a hit, exactly like the transfer branch — all three native entry points share one completion mechanism. iOS Apple is device-verified (2026-08-18); iOS/Android Google remain unverified on-device, blocked on OBJ-2528 Task 0 — code coverage is not the open gap here, device verification is.

Applies to: useClerkNative.ts's suppression watcher and App.vue's AgreeToTermsModal :open gate — anything reading pendingConsentSignUpId/signedIn together.

pendingConsentSignUpId && signedIn is not, on its own, anomalous. The router's auth guard only blocks unauthenticated access (router/index.ts:768, if (to.meta.public) { next(); return }) — nothing stops an already-signed-in user from reaching /sign-up and starting a native Apple/Google sign-up for a second, different account. That legitimately writes pendingConsentSignUpId while signedIn is still true for the first account. An earlier version of the OBJ-2602 suppression watcher (added to catch a genuine device defect — a stale on-disk consent key surviving alongside an unrelated live session) treated that combination as always anomalous, reported a false positive to Sentry, and called clearPendingConsent() — destroying the second sign-up the user was actively completing (OBJ-2660).

The fix: a provenance discriminator, not a broader condition. pendingConsentOrigin ('in-session' | 'rehydrated' | null, useClerkNative.ts) records how pendingConsentSignUpId got set:

  • 'in-session' — written by a live call this session (signInWithApple, signInWithGoogle, completeOAuthRedirect's transfer branch). This is the second-account shape — legitimate.
  • 'rehydrated' — restored from secureStorage by init() on a cold boot. This is the OBJ-2602 shape — a stale key coexisting with a live session it has nothing to do with.
  • null — the fail-closed default (also set on decline/sign-out/clearPendingConsent()).

The suppression watcher fires only when origin !== 'in-session'; App.vue's :open gate reopens the modal specifically when origin === 'in-session'. Both treat anything that isn't a confirmed in-session write — including the null default — as anomalous, so an unexpected state fails toward the original OBJ-2602 protection, not toward silently reopening the gate.

Why pendingConsentOrigin is deliberately never persisted to secureStorage. The OBJ-2602 defect is a stale on-disk value surviving a cold boot. If the origin were persisted, a stale on-disk 'in-session' value would replay as legitimate on the next init() — reintroducing the exact defect this discriminator exists to catch, just one field over. Keeping it module-scope-only means every cold boot starts from null and only earns 'in-session' by actually observing a live write in the current process.

Known accepted narrowing (Q2, OBJ-2660): if the app is killed mid-second-account sign-up, relaunch rehydrates the pending id with no way to distinguish it from a genuinely stale OBJ-2602 key — it comes back as 'rehydrated', gets treated as anomalous, and is swept. The modal does not re-present for that specific case. The original single-sign-up guarantee (UI-SPEC g21, docs/ui-specs/obj-2602-native-legal-consent.md) is unaffected; only the multi-account app-kill edge case narrows.

Sentry triage: as of OBJ-2660, a consentModal.suppressedOverLiveSession event means only the rehydrated/anomalous shape — a stale on-disk consent key found alongside a live session that has nothing to do with it (the original OBJ-2602 defect, or the Q2 app-kill edge case above). It no longer fires for an ordinary second-account sign-up while signed in — if it does, the discriminator itself has regressed, not the flow it's reporting on.

Applies to: Any cross-jar OAuth flow (e.g., WebView sign-in button → Custom Tab OAuth)

Capacitor's WebView and Android's Custom Tab (a Chrome process) maintain separate cookie jars. If you set a state cookie in the WebView and then open OAuth in the Custom Tab, the Custom Tab's clerk.objectuve.com/v1/oauth_callback cannot see the WebView's cookie — it will return authorization_invalid.

The bearer-token flow avoids this by encoding the SignIn state into the bearer token itself, not cookies. No cookie jar crossing required.

See docs/product/completed/mobile-oauth-native-bearer-prd.md for the full architecture and why cookie-based OAuth fails on Capacitor.

Two Clerk-sync entry points: syncClerkUser() (router) vs resyncClerkUser() (composable)

Applies to: any code that needs to force a fresh SYNC_USER_MUTATION round-trip and re-establish currentUserId.

ionic_frontend/src/router/index.ts's syncClerkUser() has been the only caller of SYNC_USER_MUTATIONsetSynced() for a while — it runs on the router's auth guard and owns router-local concerns (a SYNC_TIMEOUT_MS race, a resync cooldown, userSynced bookkeeping).

useClerkSync.ts now exports a second, deliberately router-free caller: resyncClerkUser() (ionic_frontend/src/composables/useClerkSync.ts:120). It mutates SYNC_USER_MUTATION, extracts the same fields syncClerkUser() does, and calls setSynced() — but skips all of the router's timeout/cooldown/userSynced bookkeeping, which stays out of scope for a composable.

Why it exists: the Dashboard streak surface's honest loading/unavailable/ready tri-state (see gamification-engine.md — Absent User Data Never Renders as a Zero Streak) fires one silent auto-retry on first entering unavailable. When currentUserId is null, USER_QUERY was never enabled in the first place, so a bare refetchUser() is a no-op — there's nothing to refetch. Dashboard.vue's retryStreak() (ionic_frontend/src/views/Dashboard.vue:937-942) now calls resyncClerkUser() first when currentUserId is null, then refetchUser(), so the retry actually re-establishes an id before refetching (OBJ-1730). The composable exists specifically so this fix didn't have to import the router module directly — doing so broke 35 existing tests, since the app router isn't mocked in the test suite.

Constraints for any future sync entry point:

  • Preserve setSynced()'s cross-account-leak safety. setSynced() always resets isAdmin to adminFlag === true (never trusting a stale prior value) and only overwrites onboardingCompletedAt when the caller passes a defined value — see OBJ-1607 above for why a sync path that skips this reset is unsafe. resyncClerkUser() routes through setSynced() for exactly this reason; don't bypass it.
  • Decide deliberately on timezone. syncClerkUser() sends variables: { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }; resyncClerkUser() currently omits it, so a resync-only sync degrades gracefully to UTC via the backend's User#timezone_or_default rather than failing — not a blocker, but worth closing for parity on streak day-boundary math (tracked separately, OBJ-1769, low-priority).
  • scripts/clerk-frontend-api-spike.mjs — spike for validating the full backend flow
  • docs/product/completed/mobile-oauth-native-bearer-prd.md — architecture, problem statement, solution
  • ionic_frontend/src/services/clerkFrontendApi.ts — native HTTP wrapper for Frontend API calls
  • ionic_frontend/src/composables/useAuthStore.ts — native-aware isSignedIn and signOut(), the correct source for auth-gated UI and sign-out
  • ionic_frontend/src/helpers/session.jsSession.logout(), the shared sign-out helper all three sign-out call sites funnel through
  • ionic_frontend/src/composables/useClerkSync.ts — shared Clerk-synced user state; resyncClerkUser() is the router-free resync entry point
  • docs/architecture/authentication.md — full native (Capacitor) auth architecture
  • ionic_frontend/src/composables/useClerkNative.tssignInWithApple(), the in-process Apple identity token exchange; recoverExistingSession(), the session_exists recovery path (OBJ-2659); signOut(), the native session teardown (secureStorage + clerkFrontendApi.endSession())
  • ionic_frontend/ios/App/App/AppleSignInPlugin.swift — local Capacitor plugin wrapping ASAuthorizationController
  • ionic_frontend/src/views/SsoCallback.vue — OAuth redirect handler; observes pendingConsentSignUpId for its own backdrop copy, does not own the consent modal (OBJ-2602 folded that ownership out of this view)
  • ionic_frontend/src/App.vue — global mount for AgreeToTermsModal, handleConsentAccept()/handleConsentDecline() (OBJ-2602)
  • ionic_frontend/src/components/AgreeToTermsModal.vue — the reused consent modal, five render states

Last updated: 2026-08-19 (OBJ-2659: added "A session_exists 400 on the in-process Apple/Google exchange means the sign-in already succeeded" — recovery into the live session, distinct from the OBJ-2077 sign-out-teardown cause of the same status code)

Loading…