Skip to content

Mobile OAuth via Clerk Native + Bearer Tokens — PRD

Product: Objectuve (codename: Enkidu) Feature: Replace WebView-cookie OAuth with bearer-token Clerk auth on Capacitor (Android + iOS) Status: ✅ Shipped (v1.13) Date: May 2026 Owner: Josh Lockhart PRD file: docs/product/mobile-oauth-native-bearer-prd.md


TL;DR

Google OAuth (and other social providers — Apple, GitHub) does not work on the Objectuve Android APK because the current @clerk/vue integration relies on cookies, and Capacitor's WebView and Android's Custom Tabs each have their own cookie jars. Email/password works because that flow is fully WebView-side; OAuth fails because the round-trip crosses jars and Clerk's /v1/oauth_callback can't see the SignIn state cookie set in the WebView.

Four implementation attempts (WebView-initiated signIn.create(), Account Portal /sso-callback path, Account Portal /sign-in page, Account Portal sign-out wrapper) each failed for distinct reasons rooted in this same cookie-jar split. Native API mode on the Clerk dashboard does not bridge it for these flows — only fully Account-Portal-completed sign-ins receive a __clerk_handshake redirect, and our App Link allowlist appears not to be matching exactly enough for Clerk to attach one.

This PRD specifies the path forward: stop using cookie-based session auth on Capacitor entirely. Use Clerk's Frontend API with a bearer token — the same model the official @clerk/expo and @clerk/clerk-react-native SDKs use — wrapped in a thin custom integration since no first-party @clerk/vue mobile SDK exists. OAuth is initiated by creating a SignIn over the Frontend API with a bearer token (no cookies), opening the resulting externalVerificationRedirectURL in a Custom Tab, and consuming the resulting __clerk_handshake token on deep-link return to mint a session. The session is held as a bearer token in SecureStorage, sent on every Apollo and REST request, and refreshed via the Frontend API.

This is several days of work — an order of magnitude more than the cookie patches we've been trying — but it is the architecture Clerk officially supports for Capacitor and the only one that survives the cookie-jar split cleanly. Web is untouched: same @clerk/vue, same Account Portal, same flow.


Problem Statement

Symptom

Tapping Continue with Google in the Android APK either:

  • Opens Google in a Custom Tab, completes OAuth, then errors with {"errors":[{"message":"Unauthorized request","code":"authorization_invalid"}]}, OR
  • Opens Account Portal, bounces back to the app in <2 seconds with no Clerk parameters in the deep link, OR
  • 404s on a missing Account Portal route.

Email/password sign-in and sign-up work normally. OAuth is the only failure surface.

Root cause

Capacitor's WebView and Android's Custom Tab (a Chrome-process window) maintain separate cookie jars. The Clerk web SDK (@clerk/vue) relies on cookies for SignIn state continuity:

  1. User taps Google in the WebView.
  2. @clerk/vue calls signIn.create({ strategy: 'oauth_google', redirectUrl: ... }). This sets a __clerk_db_jwt and SignIn state cookie in the WebView's cookie jar.
  3. The returned externalVerificationRedirectURL (a Google OAuth URL with a Clerk state param) is opened in a Custom Tab — required by Google's secure-browsers policy, which rejects WebViews with disallowed_useragent.
  4. User authenticates with Google. Google redirects to https://clerk.objectuve.com/v1/oauth_callback?code=...&state=...opened in the Custom Tab cookie jar.
  5. clerk.objectuve.com/v1/oauth_callback looks for the SignIn state cookie in this request's cookie jar — finds none — returns authorization_invalid. The OAuth flow dies before it ever redirects back to the app.

Native API mode on the Clerk dashboard does not solve this. Native API attaches a __clerk_handshake token to redirects only on flows fully completed inside Account Portal (which runs entirely in one cookie jar — the Custom Tab's). It does not retroactively bridge a WebView-created SignIn to a Custom Tab callback.

Failure log (chronological)

#ApproachOutcomeWhy it failed
1WebView calls signIn.create(), opens externalVerificationRedirectURL (Google) in Custom Tabauthorization_invalid from clerk.objectuve.com/v1/oauth_callbackSignIn state cookie in WebView jar; callback runs in Custom Tab jar
2Open accounts.objectuve.com/sign-in/sso-callback?strategy=oauth_google&redirect_url=... in Custom TabAccount Portal stripped path, redirected to bare /sign-in, then to redirect URL with no params/sso-callback is the return path; cold-loading it is a no-op
3Open accounts.objectuve.com/sign-in?redirect_url=... in Custom Tab1.2-second instant bounce-back to app with no Clerk paramsStale Account Portal session in Custom Tab cookie jar fired after_sign_in_url without minting a fresh handshake
4Force Account Portal sign-out first via /sign-out?redirect_url=<sign-in-url>404 — Account Portal does not expose a public sign-out URLRoute doesn't exist

The pattern across attempts: @clerk/vue web SDK + Capacitor WebView is structurally incompatible with cross-jar OAuth. Each cookie-based variant fails for its own reason; none can succeed because the underlying primitive (cookies) doesn't cross the jar boundary.

Spike attempt log — v1.13 follow-ups

Three attempts to fix Google + GitHub OAuth across v1.13.0 → v1.13.1 refined the approach:

Spike #AttemptOutcomeLearning
1v1.13.0: browser fetch() from WebView, relying on Clerk SDK to handle statePOST /v1/client/sign_ins triggered browser-detection heuristic; Clerk fell into cookie/browser mode instead of bearer mode. Authorization header always null.Native calls to fetch() mimic browser Origin header behavior, triggering Clerk's old paths. Cannot use fetch() at all on native.
2OBJ-279: window.Clerk.client.signIn.create() from WebView + Custom Tab OAuthBroke the same way as Spike #1. Browser SignIn state set in WebView jar → Custom Tab callback couldn't see it → authorization_invalid.window.Clerk.* SDK calls inherit the WebView's cookie context, making them indistinguishable from browser-based calls. Using the SDK defeats the point of the bearer-token approach.
3OBJ-285 (shipped): @capacitor/http (native Java/Kotlin HTTP layer) with Frontend API bearer tokensGoogle + GitHub OAuth completes end-to-end; sessions persist; token refresh works.@capacitor/http routes requests through the native layer with no browser context or Origin header. Clerk treats them as bearer-token requests and encodes state in the token itself. Cookie jar is irrelevant. This is the documented pattern for all Capacitor apps.

Forward-looking rule: Any Frontend API call on native must route through @capacitor/http (via clerkFrontendApi.ts). Never fetch(). Never window.Clerk.* SDK calls in the OAuth path. Both reintroduce cookie logic that defeats the bearer-token architecture.

Constraint: Google's policy is non-negotiable

OAuth must run in a Custom Tab (or SFSafariViewController on iOS). Google detects and rejects WebViews via disallowed_useragent; allowNavigation overrides in capacitor.config.ts either let Google reject inline or violate Google's Terms. We cannot solve this by opening Google in the WebView. The Custom Tab is mandatory. The cookie-jar split is therefore mandatory. The fix has to live above cookies.


Goals & Success Metrics

Primary Goals

  1. Tapping Continue with Google / Apple / GitHub on the Android APK and iOS app completes OAuth and lands the user signed in on /dashboard.
  2. Email/password sign-in continues to work without regression.
  3. Web (app.objectuve.com in a real browser) is untouched — same @clerk/vue, same Account Portal, same flow.
  4. Session persists across app restarts on mobile (token in SecureStorage, refreshed before expiry).
  5. Multi-tab sign-out detection (the storage event handler in App.vue) and the stale-session cleanup in useStaleSession.ts continue to work.

Success Metrics

MetricTarget
Google OAuth sign-in completion rate (Android APK)≥95% (excludes user cancellations)
Google OAuth sign-in completion rate (iOS)≥95%
OAuth flow median duration (tap → /dashboard)≤8 seconds
Email/password regression rate0 (guardrail)
Web sign-in regression rate0 (guardrail)
Token refresh success rate≥99% (failure forces re-sign-in — acceptable but should be rare)
Crash-free OAuth sessions (Sentry)≥99.5%

Guardrails

  • Web flow untouched. Any change to @clerk/vue configuration must be Capacitor-only — gated by Capacitor.isNativePlatform().
  • No secrets in the APK. Clerk publishable key is fine (it's public). No Clerk secret key, no service tokens.
  • Bearer tokens stored only in @aparajita/capacitor-secure-storage (OS-level encryption: iOS Keychain, Android AES-GCM/KeyStore). Never in plain localStorage on native.
  • Same backend. rails_api/ Clerk JWT verification (ClerkJwtVerifier) is unchanged. The bearer token issued via Frontend API is still a Clerk RS256 JWT verifiable via the same JWKS endpoint.

Background: how Clerk auth works today

Web flow (works)

[Browser]
  └─ @clerk/vue mounted → useClerk() → window.Clerk
      └─ <SignIn> component (from @clerk/vue) renders
          └─ User clicks Google
              └─ Clerk redirects to accounts.google.com (top-level navigation, same jar)
                  └─ Google → clerk.objectuve.com/v1/oauth_callback (same jar — cookies present)
                      └─ Clerk sets __session cookie, redirects to /dashboard
                          └─ Apollo middleware reads `Clerk.session.getToken()` → sends as `SessionToken` header
                              └─ rails_api/ClerkJwtVerifier verifies via JWKS → context[:current_user]

Cookies work because everything happens in one browser context — one cookie jar.

Capacitor flow (broken for OAuth)

[WebView — cookie jar A]
  └─ @clerk/vue mounted same as web
      └─ User taps Google (we hide Clerk's button on native and intercept)
          └─ JS calls signIn.create() → Clerk sets state cookie in jar A
              └─ Open externalVerificationRedirectURL in Custom Tab

[Custom Tab — cookie jar B — different process]
                  └─ Google OAuth completes
                      └─ Clerk callback runs HERE — looks for state cookie → not in jar B → 401

The boundary between WebView and Custom Tab is the cookie-jar break. The fix is to not depend on cookies across that boundary.

What Clerk offers as the "mobile" path

Clerk's mobile pattern, as used by @clerk/expo:

  1. Frontend API (https://clerk.<domain>/v1/...) — same endpoints @clerk/vue uses internally, but accessible directly with a Authorization: Bearer <token> header instead of cookies.
  2. Long-lived "client" token — issued via POST /v1/client when the SDK initializes; serves as the persistent identity for that device install. Stored in encrypted storage.
  3. Per-request session token — short-lived (10 min) RS256 JWT, fetched via GET /v1/client/sessions/<sid>/tokens, sent as the SessionToken header to our Rails API. Same token shape as web today.
  4. OAuth via the Custom TabsignIn.create() over Frontend API with the bearer client token; Clerk encodes the SignIn state into the URL it returns; Custom Tab opens that URL; Clerk's callback redirects to a deep link with __clerk_handshake=...; the app POSTs the handshake back over Frontend API (with the bearer client token) to mint a session.

Cookies never come into it. The bearer client token is the only persistent state, and it lives in encrypted storage on the device.

There is no first-party @clerk/vue Capacitor SDK, but the Frontend API is just HTTP — wrapping it for Capacitor is a few hundred lines of code at most.


Proposed Architecture

Three layers

┌─────────────────────────────────────────────────────────────┐
│  ionic_frontend/src/                                         │
│                                                              │
│  ┌──────────────────────┐    ┌─────────────────────────┐   │
│  │ @clerk/vue (web)     │    │ useClerkNative.ts (new) │   │
│  │ when !isNative       │    │ when isNative           │   │
│  │ — unchanged          │    │ — bearer-token Frontend │   │
│  │                      │    │   API wrapper           │   │
│  └──────────────────────┘    └─────────────────────────┘   │
│           ↓                            ↓                     │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  authStore (composable) — single facade              │   │
│  │  exposes: getSessionToken(), signOut(), user, ready │   │
│  └─────────────────────────────────────────────────────┘   │
│                       ↓                                      │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  apollo-client.ts — authLink reads getSessionToken() │   │
│  │  from authStore. No changes to header name.          │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

The authStore facade is the only change apollo-client.ts and the rest of the app see. On web it delegates to @clerk/vue's useAuth(); on native it delegates to useClerkNative(). Everything downstream is identical.

useClerkNative.ts surface

typescript
// ionic_frontend/src/composables/useClerkNative.ts
export function useClerkNative() {
  return {
    ready: Ref<boolean>,                      // becomes true after client token loaded/created
    user: Ref<ClerkUser | null>,              // populated after sign-in
    signedIn: ComputedRef<boolean>,
    getSessionToken(): Promise<string | null>,// short-lived JWT for SessionToken header
    signInWithOAuth(strategy: OAuthStrategy): Promise<void>,
    signInWithEmail(email: string, code: string): Promise<void>, // optional Phase 2
    signOut(): Promise<void>,
    completeOAuthRedirect(deepLinkUrl: string): Promise<void>,
  }
}

getSessionToken() is the hot path — it's called on every Apollo request via the existing authLink. Cache aggressively (10-min TTL, refresh in background ~30s before expiry).

Frontend API endpoints used

All against https://clerk.objectuve.com/v1/. Authorization is Authorization: Bearer <client_token>.

EndpointMethodPurpose
/v1/client?_clerk_js_version=...POST (no auth)Initialize device, get client token (returned in Authorization response header)
/v1/clientGETRefresh client state, list sessions
/v1/client/sign_insPOSTCreate SignIn with strategy=oauth_google&redirect_url=...&action_complete_redirect_url=...
/v1/client/sign_ins/<sid>/attempt_first_factorPOST(only if non-OAuth strategies in Phase 2)
/v1/client/handshakePOSTExchange handshake token from deep-link callback for session
/v1/client/sessions/<sid>/tokensPOSTMint a short-lived session JWT (refresh path)
/v1/client/sessions/<sid>/endPOSTSign out

The exact request/response shapes are documented at https://clerk.com/docs/reference/frontend-api. The implementing engineer should pin to a specific Frontend API version (__clerk_js_version=5.x.x) and confirm shapes against that version's docs.

OAuth flow with bearer tokens

[App boot]
  authStore.init()
    → useClerkNative.init()
      → check Preferences for client_token
      → if absent: POST /v1/client → store client_token in SecureStorage
      → if present: GET /v1/client (with bearer) → confirm valid; refresh if expired

[User taps Continue with Google]
  signInWithOAuth('oauth_google')
    → POST /v1/client/sign_ins with bearer client_token
        body: { strategy: 'oauth_google',
                redirect_url: 'https://app.objectuve.com/sso-callback',
                action_complete_redirect_url: 'https://app.objectuve.com/dashboard' }
    → response includes signIn.first_factor_verification.external_verification_redirect_url
    → Browser.open(externalUrl, presentationStyle: 'popover')

[Custom Tab — Google OAuth]
  → user authenticates
  → Google redirects to clerk.objectuve.com/v1/oauth_callback?code=...&state=...
  → Clerk has the SignIn state IN THE BEARER TOKEN ITSELF (not in cookies!)
    because we used Frontend API. So /v1/oauth_callback validates by state token,
    not cookie. No cookie-jar problem.
  → Clerk redirects to https://app.objectuve.com/sso-callback?__clerk_handshake=<token>

[App receives deep link]
  App.vue appUrlOpen handler → router.push('/sso-callback?__clerk_handshake=<token>')
  → SsoCallback view → authStore.completeOAuthRedirect(url)
    → useClerkNative.completeOAuthRedirect(url)
      → extract __clerk_handshake token from URL
      → POST /v1/client/handshake with bearer client_token + handshake token
      → response includes: { session_id, last_active_session_id }
      → fetch session JWT via POST /v1/client/sessions/<sid>/tokens
      → store session_id in SecureStorage
      → cache JWT in memory (10-min TTL)
      → router.push('/dashboard')

[Apollo request]
  authLink → authStore.getSessionToken()
    → if cached and >30s remaining: return cached
    → else: POST /v1/client/sessions/<sid>/tokens → cache → return
  → header: SessionToken: <jwt>
  → rails_api/ClerkJwtVerifier verifies via JWKS — same as web flow

The critical insight: signIn.create() over Frontend API encodes the SignIn state into the Bearer token used to make the request, not into cookies. Clerk's /v1/oauth_callback then validates by the OAuth state param + the Bearer-token-bound SignIn record, not by reading cookies in the Custom Tab. The cookie jar is irrelevant because cookies aren't used.

Why this hasn't worked in our previous attempts

Previous attempts called signIn.create() via window.Clerk.client.signIn.create(...) from the WebView. That uses the @clerk/vue (clerk-js) instance, which is cookie-based. Even though the Frontend API endpoint is the same one a bearer-token client would call, clerk-js sets a cookie alongside the request, and Clerk's callback honors the cookie path because clerk-js told it to. The fix is to bypass clerk-js entirely on native and call Frontend API directly with Authorization: Bearer ....


Technical Design

File-level changes

New files

  • ionic_frontend/src/composables/useClerkNative.ts — Frontend API client wrapper, ~400 lines
  • ionic_frontend/src/composables/useAuthStore.ts — facade over @clerk/vue (web) or useClerkNative (native), ~80 lines
  • ionic_frontend/src/services/clerkFrontendApi.ts — thin HTTP client for clerk.objectuve.com/v1/* with Bearer auth, retry, version pinning, ~200 lines
  • ionic_frontend/src/services/secureStorage.ts — wrapper around @aparajita/capacitor-secure-storage (key prefix: objectuve_clerk_) for client token / session ID persistence, ~60 lines
  • ionic_frontend/tests/unit/composables/useClerkNative.test.ts — Vitest with fetch mocked
  • ionic_frontend/tests/unit/composables/useAuthStore.test.ts

Modified files

  • ionic_frontend/src/apollo-client.ts — replace Clerk.session.getToken() call in authLink with authStore.getSessionToken(). Single line change if the facade is shaped right.
  • ionic_frontend/src/App.vue — replace useStaleSession call site with authStore.detectAndClearStale(). The appUrlOpen handler stays as-is (it already routes Clerk-param URLs to /sso-callback).
  • ionic_frontend/src/views/SsoCallback.vue — call authStore.completeOAuthRedirect(window.location.href) instead of useNativeOAuth().completeRedirect(...).
  • ionic_frontend/src/views/SignIn.vue — replace native-mode useNativeOAuth().startOAuth(strategy) with authStore.signInWithOAuth(strategy). Web mode (Clerk's <SignIn> component) stays untouched.
  • ionic_frontend/src/router/index.ts — replace Clerk.session.getToken() call in beforeEach guard with authStore.getSessionToken(). Replace Clerk.user checks with authStore.user.value.
  • ionic_frontend/package.json — no change required if @aparajita/capacitor-secure-storage@^8.0.0 already present; otherwise add it.

Files to delete (after migration verified)

  • ionic_frontend/src/composables/useNativeOAuth.ts — superseded by useClerkNative.ts
  • The native-only branches inside SignIn.vue that hide Clerk social buttons via CSS are no longer needed in their current form — SignIn.vue should detect native and render its own OAuth buttons that call authStore.signInWithOAuth(). Mounted Clerk's <SignIn> only on web.

Storage layout

@aparajita/capacitor-secure-storage (key prefix: objectuve_clerk_):

KeyValueSet whenCleared when
client_tokenClerk client bearer tokenFirst POST /v1/client succeedssignOut(), or token rotation response
session_idActive Clerk session IDAfter successful /handshakesignOut(), session expiry detected

Session JWTs (the short-lived ones sent as SessionToken) are not persisted — they live in memory only. Restart re-mints them via the persisted session_id + client_token.

Multi-tab sign-out detection (FEUX-10) — keep working

Today: App.vue listens for storage events keyed on objectuve_user_public_id and signs out when another tab cleared it. On native there's no storage event because there are no multiple tabs. The native code path can no-op; useClerkNative.signOut() clears its own state directly and apolloClient.clearStore() is called from the same place.

Stale session cleanup (FEUX-11) — keep working

Today: detectAndClearStaleSession() in useStaleSession.ts runs once on mount. It checks for a Clerk cookie without a corresponding user. On native, the equivalent is: if client_token is present but GET /v1/client returns 401, clear client_token and route to /sign-in. Bake this into useClerkNative.init().

Backend (rails_api) — no changes

rails_api/app/services/clerk_jwt_verifier.rb already verifies RS256 JWTs via JWKS. The bearer session JWT minted by Frontend API is the same shape as the cookie-issued one — same issuer, same audience, same JWKS. Zero backend changes.

This is non-trivial to confirm — the implementing engineer must verify this experimentally on the production Clerk instance before committing. If the JWT shape differs, that's a Phase 1.5 fix (Rails accepts a different audience claim, or both audiences via array config). Spike it on day 1.

Capacitor + Android specifics

  • capacitor.config.ts — no change required. The current server.hostname: app.objectuve.com and absence of OAuth-host allowNavigation are correct for this design.
  • MainActivity.java cookie acceptance code — keep. It's still needed for the WebView's @clerk/vue (used for email/password and any <SignIn> component on native if we keep one as fallback).
  • App Links / assetlinks.json — verify production hosting at https://app.objectuve.com/.well-known/assetlinks.json includes the release keystore SHA-256. Without it, deep-link return falls back to the Android chooser dialog (works, but worse UX). Already noted in the prior failed plan.
  • AndroidManifest.xml intent filter for https://app.objectuve.com/* with autoVerify="true" — already in place.

iOS specifics (parity work)

  • Universal Links — confirm apple-app-site-association is hosted at https://app.objectuve.com/.well-known/apple-app-site-association with the iOS bundle ID and team ID. Required for Browser.open() to deep-link back into the app via SFSafariViewController.
  • Keychain encryption — @aparajita/capacitor-secure-storage uses iOS Keychain for encryption automatically; no configuration needed.
  • iOS does not have the disallowed_useragent rejection (Apple Sign-In and Google both work in WKWebView), but the bearer-token architecture should be used on iOS too — uniformity matters more than letting iOS keep cookies.

Implementation Phases

Phase 0 — Spike (1 day)

Goal: confirm the Frontend API bearer flow works end-to-end on a single device before committing to full implementation.

  • Standalone Node script that:
    1. POSTs /v1/client against production Clerk instance
    2. Captures the bearer client token
    3. POSTs /v1/client/sign_ins with strategy=oauth_google&redirect_url=https://app.objectuve.com/sso-callback
    4. Logs the returned external_verification_redirect_url
  • Manual: paste that URL into a phone Chrome, complete Google OAuth, capture the deep link or /sso-callback?__clerk_handshake=... URL
  • POST /v1/client/handshake with the captured handshake — confirm a session is returned
  • POST /v1/client/sessions/<sid>/tokens — confirm a JWT is returned
  • Verify the JWT is valid by calling app.objectuve.com/graphql with SessionToken: <jwt> — should return a logged-in viewer

If any of these fail, stop and re-architect — the rest of the PRD assumes this spike succeeds. Most likely failure mode: Frontend API requires a different auth header on the production instance, or rejects bearer auth without a CSRF token. Both are addressable but change the implementation shape.

Phase 1 — Native auth wrapper (2–3 days)

  • secureStorage.ts + clerkFrontendApi.ts + useClerkNative.ts + useAuthStore.ts
  • Vitest unit tests for each (mocked fetch)
  • No UI integration yet

Phase 2 — Wire into the app (1 day)

  • apollo-client.ts, App.vue, SsoCallback.vue, SignIn.vue, router/index.ts — swap to facade
  • Behind a feature flag (e.g., VITE_ENABLE_NATIVE_AUTH=true) so the existing useNativeOAuth.ts flow stays as a fallback during testing

Phase 3 — Test on Android APK (1 day)

  • Build release APK with VITE_ENABLE_NATIVE_AUTH=true
  • Test: Google OAuth, email/password, sign-out, restart-while-signed-in, multi-account switch, force-quit-and-resume
  • Capture Sentry breadcrumbs for the OAuth path; confirm no errors

Phase 4 — Test on iOS (1 day, may overlap with Phase 3)

  • Same matrix as Android
  • Universal Links verification — confirm deep-link return works without the chooser dialog

Phase 5 — Remove the feature flag and old code (0.5 days)

  • Delete useNativeOAuth.ts
  • Delete the CSS-hide rules in SignIn.vue for Clerk social buttons (replace with conditional render)
  • Delete the VITE_ENABLE_NATIVE_AUTH flag and force-on the new path on native

Total: 5–7 working days, including spike. Plan for 8–10 calendar days with review and retesting.


Risks & Open Questions

Critical risks

  1. Frontend API may not accept bearer-token auth on the production Clerk instance. Some Clerk customers have reported needing a separate __client JWT or a CSRF token on top of the bearer. Mitigation: Phase 0 spike. If spike fails, escalate to Clerk support — the official documented mobile pattern uses bearer auth, so this is a config issue, not a Clerk-blocker.
  2. The session JWT shape from Frontend API may differ from cookie-issued JWTs, breaking rails_api/ClerkJwtVerifier. Mitigation: Phase 0 step verifies the JWT against /graphql. If it differs, accept both audiences in Rails config — small change.
  3. Token refresh races. Multiple Apollo requests in flight when the cached token expires can stampede /v1/client/sessions/<sid>/tokens. Mitigation: in-flight Promise dedup in useClerkNative.getSessionToken() — single fetch, all callers await the same Promise.
  4. App Link verification on production hosting. If assetlinks.json isn't served correctly, the Android Chooser dialog appears on deep-link return. Verify before Phase 3.

Open questions for the implementing engineer

  1. Does Clerk's Frontend API support OAuth strategies the same way for sign-up vs. sign-in? Or is there a separate /v1/client/sign_ups flow? (Spike will surface this.)
  2. Should client_token rotate on schedule, or only on 401? Clerk's behavior here is undocumented for @clerk/expo parity — read the SDK source or ask Clerk support.
  3. How does the user-deleted case propagate? When a user is deleted server-side, the session JWT stops verifying immediately, but client_token still works. We'd want to surface "this account no longer exists" rather than silently re-sign-in. (Defer to Phase 6 if unclear.)
  4. Does @capacitor/preferences encrypted mode actually use Android Keystore on the production targets, or does it fall back to plain SharedPreferences on older devices? Verify with Preferences.configure({ group: ..., encrypted: true }) and a runtime check. Resolved: @aparajita/capacitor-secure-storage is always encrypted using Android Keystore (AES-GCM) and iOS Keychain; no fallback to plain storage.

Non-risks (intentionally not addressed)

  • iOS App Tracking Transparency — irrelevant; we're not tracking across apps.
  • Account linking (same email signed in via Google + Apple separately) — handled by Clerk server-side; no client work.
  • Third-party OAuth providers beyond Google/Apple/GitHub — out of scope; same flow extends to any provider Clerk supports.

Acceptance Criteria

A reviewer should be able to verify each of these in 30 minutes on a fresh release APK and TestFlight build:

  1. Google OAuth on Android. Tap Continue with Google → Custom Tab opens to Google → complete sign-in → app foregrounds at /dashboard with the user signed in. Total time ≤10s. No errors in adb logcat | grep -iE "Clerk|capacitor".
  2. Apple OAuth on iOS. Same as above but with SFSafariViewController and Apple Sign-In.
  3. GitHub OAuth on both platforms. Same as above (lower priority — test if time permits).
  4. Email/password regression. Sign in with demo@objectuve.com / demo password (per docs/architecture/authentication.md). Lands on /dashboard. No regression vs. current APK behavior.
  5. Sign out and back in. From /settings, tap Sign Out. Lands on /sign-in. Tap Continue with Google. Lands on /dashboard again. Token in storage is different from the previous session.
  6. Restart-while-signed-in. Sign in. Force-quit the app. Reopen. Lands on /dashboard without re-auth. Apollo queries succeed (verifies session token refresh).
  7. Stale session cleanup. Sign in. Manually corrupt session_id in storage (e.g., via Chrome DevTools when targeting the WebView). Reopen app. Lands on /sign-in — no infinite redirect loop, no spinner.
  8. Multi-tab sign-out (web). Sign in to web in tab A. Sign in to same account in tab B. Sign out in tab B. Tab A within 5 seconds redirects to /sign-in. No regression of FEUX-10.
  9. Web flow untouched. Visit https://app.objectuve.com/sign-in in a desktop browser. Tap Continue with Google. Flow completes exactly as it did pre-change.
  10. Network failure during OAuth. Toggle airplane mode mid-flow. App surfaces a sensible error and routes to /sign-in — does not get stuck on a spinner.
  11. Sentry coverage. All useClerkNative errors are reported to Sentry with breadcrumbs identifying the failing API endpoint.
  12. No regressions in rails_api. Existing ClerkJwtVerifier specs and request specs pass unchanged.

Out of Scope

  • Migrating off Clerk. This PRD assumes Clerk stays. Different question.
  • Custom OAuth (no Clerk). Building a Rails-side OAuth flow against Google/Apple directly. Several weeks of work, regulatory implications (Apple Sign-In requirements), out of scope.
  • Deprecating @clerk/vue on web. Web continues to use it. Only native gets the new wrapper.
  • Magic-link sign-in on native. Phase 2 if requested; not blocking the OAuth fix.
  • MFA enrollment / second-factor flows. None of our users have MFA enrolled today; out of scope until enabled.
  • iOS Sign In with Apple system UI (full-screen native modal vs. Safari) — Capacitor's Apple Sign-In plugin can be used here later for parity with Apple HIG. Out of scope for this PRD.

References


Appendix A: Why Path 3 (defer OAuth on mobile) was rejected as the long-term answer

Path 3 ships email/password-only on the APK and adds OAuth in a later release. It is the right short-term answer if launch is blocked. But:

  • 60–80% of new sign-ups on web use Google OAuth (per current Clerk analytics dashboard).
  • Mobile-first users disproportionately come from social-OAuth-heavy ecosystems (Android with Google account already on device).
  • Maintaining two auth UX shapes (web has Google, mobile doesn't) is itself a support burden — users repeatedly try to sign in with Google on mobile and get confused.

Ship email/password-only if needed to unblock a launch, but do not let it become permanent. Path 2 is the architecture for any Capacitor app that wants Clerk + OAuth, full stop.


Last updated: 2026-05-23 — Phase 100 audit. PRD for shipped feature (v1.13) archived to completed/.

Loading…