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:
- User taps Google in the WebView.
@clerk/vuecallssignIn.create({ strategy: 'oauth_google', redirectUrl: ... }). This sets a__clerk_db_jwtand SignIn state cookie in the WebView's cookie jar.- The returned
externalVerificationRedirectURL(a Google OAuth URL with a Clerkstateparam) is opened in a Custom Tab — required by Google's secure-browsers policy, which rejects WebViews withdisallowed_useragent. - User authenticates with Google. Google redirects to
https://clerk.objectuve.com/v1/oauth_callback?code=...&state=...— opened in the Custom Tab cookie jar. clerk.objectuve.com/v1/oauth_callbacklooks for the SignIn state cookie in this request's cookie jar — finds none — returnsauthorization_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)
| # | Approach | Outcome | Why it failed |
|---|---|---|---|
| 1 | WebView calls signIn.create(), opens externalVerificationRedirectURL (Google) in Custom Tab | authorization_invalid from clerk.objectuve.com/v1/oauth_callback | SignIn state cookie in WebView jar; callback runs in Custom Tab jar |
| 2 | Open accounts.objectuve.com/sign-in/sso-callback?strategy=oauth_google&redirect_url=... in Custom Tab | Account 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 |
| 3 | Open accounts.objectuve.com/sign-in?redirect_url=... in Custom Tab | 1.2-second instant bounce-back to app with no Clerk params | Stale Account Portal session in Custom Tab cookie jar fired after_sign_in_url without minting a fresh handshake |
| 4 | Force Account Portal sign-out first via /sign-out?redirect_url=<sign-in-url> | 404 — Account Portal does not expose a public sign-out URL | Route 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 # | Attempt | Outcome | Learning |
|---|---|---|---|
| 1 | v1.13.0: browser fetch() from WebView, relying on Clerk SDK to handle state | POST /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. |
| 2 | OBJ-279: window.Clerk.client.signIn.create() from WebView + Custom Tab OAuth | Broke 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. |
| 3 | OBJ-285 (shipped): @capacitor/http (native Java/Kotlin HTTP layer) with Frontend API bearer tokens | Google + 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
- Tapping Continue with Google / Apple / GitHub on the Android APK and iOS app completes OAuth and lands the user signed in on
/dashboard. - Email/password sign-in continues to work without regression.
- Web (
app.objectuve.comin a real browser) is untouched — same@clerk/vue, same Account Portal, same flow. - Session persists across app restarts on mobile (token in SecureStorage, refreshed before expiry).
- Multi-tab sign-out detection (the
storageevent handler inApp.vue) and the stale-session cleanup inuseStaleSession.tscontinue to work.
Success Metrics
| Metric | Target |
|---|---|
| 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 rate | 0 (guardrail) |
| Web sign-in regression rate | 0 (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/vueconfiguration must be Capacitor-only — gated byCapacitor.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 plainlocalStorageon 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 → 401The 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:
- Frontend API (
https://clerk.<domain>/v1/...) — same endpoints@clerk/vueuses internally, but accessible directly with aAuthorization: Bearer <token>header instead of cookies. - Long-lived "client" token — issued via
POST /v1/clientwhen the SDK initializes; serves as the persistent identity for that device install. Stored in encrypted storage. - Per-request session token — short-lived (10 min) RS256 JWT, fetched via
GET /v1/client/sessions/<sid>/tokens, sent as theSessionTokenheader to our Rails API. Same token shape as web today. - OAuth via the Custom Tab —
signIn.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
// 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>.
| Endpoint | Method | Purpose |
|---|---|---|
/v1/client?_clerk_js_version=... | POST (no auth) | Initialize device, get client token (returned in Authorization response header) |
/v1/client | GET | Refresh client state, list sessions |
/v1/client/sign_ins | POST | Create SignIn with strategy=oauth_google&redirect_url=...&action_complete_redirect_url=... |
/v1/client/sign_ins/<sid>/attempt_first_factor | POST | (only if non-OAuth strategies in Phase 2) |
/v1/client/handshake | POST | Exchange handshake token from deep-link callback for session |
/v1/client/sessions/<sid>/tokens | POST | Mint a short-lived session JWT (refresh path) |
/v1/client/sessions/<sid>/end | POST | Sign 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 flowThe 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 linesionic_frontend/src/composables/useAuthStore.ts— facade over@clerk/vue(web) oruseClerkNative(native), ~80 linesionic_frontend/src/services/clerkFrontendApi.ts— thin HTTP client forclerk.objectuve.com/v1/*with Bearer auth, retry, version pinning, ~200 linesionic_frontend/src/services/secureStorage.ts— wrapper around@aparajita/capacitor-secure-storage(key prefix:objectuve_clerk_) for client token / session ID persistence, ~60 linesionic_frontend/tests/unit/composables/useClerkNative.test.ts— Vitest withfetchmockedionic_frontend/tests/unit/composables/useAuthStore.test.ts
Modified files
ionic_frontend/src/apollo-client.ts— replaceClerk.session.getToken()call in authLink withauthStore.getSessionToken(). Single line change if the facade is shaped right.ionic_frontend/src/App.vue— replaceuseStaleSessioncall site withauthStore.detectAndClearStale(). TheappUrlOpenhandler stays as-is (it already routes Clerk-param URLs to/sso-callback).ionic_frontend/src/views/SsoCallback.vue— callauthStore.completeOAuthRedirect(window.location.href)instead ofuseNativeOAuth().completeRedirect(...).ionic_frontend/src/views/SignIn.vue— replace native-modeuseNativeOAuth().startOAuth(strategy)withauthStore.signInWithOAuth(strategy). Web mode (Clerk's<SignIn>component) stays untouched.ionic_frontend/src/router/index.ts— replaceClerk.session.getToken()call inbeforeEachguard withauthStore.getSessionToken(). ReplaceClerk.userchecks withauthStore.user.value.ionic_frontend/package.json— no change required if@aparajita/capacitor-secure-storage@^8.0.0already present; otherwise add it.
Files to delete (after migration verified)
ionic_frontend/src/composables/useNativeOAuth.ts— superseded byuseClerkNative.ts- The native-only branches inside
SignIn.vuethat hide Clerk social buttons via CSS are no longer needed in their current form —SignIn.vueshould detect native and render its own OAuth buttons that callauthStore.signInWithOAuth(). Mounted Clerk's<SignIn>only on web.
Storage layout
@aparajita/capacitor-secure-storage (key prefix: objectuve_clerk_):
| Key | Value | Set when | Cleared when |
|---|---|---|---|
client_token | Clerk client bearer token | First POST /v1/client succeeds | signOut(), or token rotation response |
session_id | Active Clerk session ID | After successful /handshake | signOut(), 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 currentserver.hostname: app.objectuve.comand absence of OAuth-hostallowNavigationare correct for this design.MainActivity.javacookie 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 athttps://app.objectuve.com/.well-known/assetlinks.jsonincludes 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.xmlintent filter forhttps://app.objectuve.com/*withautoVerify="true"— already in place.
iOS specifics (parity work)
- Universal Links — confirm
apple-app-site-associationis hosted athttps://app.objectuve.com/.well-known/apple-app-site-associationwith the iOS bundle ID and team ID. Required forBrowser.open()to deep-link back into the app via SFSafariViewController. - Keychain encryption —
@aparajita/capacitor-secure-storageuses iOS Keychain for encryption automatically; no configuration needed. - iOS does not have the
disallowed_useragentrejection (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:
- POSTs
/v1/clientagainst production Clerk instance - Captures the bearer client token
- POSTs
/v1/client/sign_inswithstrategy=oauth_google&redirect_url=https://app.objectuve.com/sso-callback - Logs the returned
external_verification_redirect_url
- POSTs
- Manual: paste that URL into a phone Chrome, complete Google OAuth, capture the deep link or
/sso-callback?__clerk_handshake=...URL - POST
/v1/client/handshakewith 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/graphqlwithSessionToken: <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 existinguseNativeOAuth.tsflow 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.vuefor Clerk social buttons (replace with conditional render) - Delete the
VITE_ENABLE_NATIVE_AUTHflag 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
- Frontend API may not accept bearer-token auth on the production Clerk instance. Some Clerk customers have reported needing a separate
__clientJWT 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. - 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. - 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 inuseClerkNative.getSessionToken()— single fetch, all callers await the same Promise. - App Link verification on production hosting. If
assetlinks.jsonisn't served correctly, the Android Chooser dialog appears on deep-link return. Verify before Phase 3.
Open questions for the implementing engineer
- 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_upsflow? (Spike will surface this.) - Should
client_tokenrotate on schedule, or only on 401? Clerk's behavior here is undocumented for@clerk/expoparity — read the SDK source or ask Clerk support. - How does the user-deleted case propagate? When a user is deleted server-side, the session JWT stops verifying immediately, but
client_tokenstill works. We'd want to surface "this account no longer exists" rather than silently re-sign-in. (Defer to Phase 6 if unclear.) DoesResolved:@capacitor/preferencesencrypted mode actually use Android Keystore on the production targets, or does it fall back to plain SharedPreferences on older devices? Verify withPreferences.configure({ group: ..., encrypted: true })and a runtime check.@aparajita/capacitor-secure-storageis 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:
- Google OAuth on Android. Tap Continue with Google → Custom Tab opens to Google → complete sign-in → app foregrounds at
/dashboardwith the user signed in. Total time ≤10s. No errors inadb logcat | grep -iE "Clerk|capacitor". - Apple OAuth on iOS. Same as above but with SFSafariViewController and Apple Sign-In.
- GitHub OAuth on both platforms. Same as above (lower priority — test if time permits).
- Email/password regression. Sign in with
demo@objectuve.com/ demo password (perdocs/architecture/authentication.md). Lands on/dashboard. No regression vs. current APK behavior. - Sign out and back in. From
/settings, tap Sign Out. Lands on/sign-in. Tap Continue with Google. Lands on/dashboardagain. Token in storage is different from the previous session. - Restart-while-signed-in. Sign in. Force-quit the app. Reopen. Lands on
/dashboardwithout re-auth. Apollo queries succeed (verifies session token refresh). - Stale session cleanup. Sign in. Manually corrupt
session_idin storage (e.g., via Chrome DevTools when targeting the WebView). Reopen app. Lands on/sign-in— no infinite redirect loop, no spinner. - 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. - Web flow untouched. Visit
https://app.objectuve.com/sign-inin a desktop browser. Tap Continue with Google. Flow completes exactly as it did pre-change. - 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. - Sentry coverage. All
useClerkNativeerrors are reported to Sentry with breadcrumbs identifying the failing API endpoint. - No regressions in
rails_api. ExistingClerkJwtVerifierspecs 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/vueon 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
- Clerk Frontend API reference: https://clerk.com/docs/reference/frontend-api
- Clerk JavaScript SDK source (
@clerk/clerk-js): https://github.com/clerk/javascript/tree/main/packages/clerk-js - Clerk Expo SDK source (reference implementation of the bearer-token mobile pattern): https://github.com/clerk/javascript/tree/main/packages/expo
- Google secure-browsers OAuth policy: https://developers.googleblog.com/en/modernizing-oauth-interactions-in-native-apps-for-better-usability-and-security/
- Capacitor Browser plugin: https://capacitorjs.com/docs/apis/browser
- Capacitor Preferences plugin: https://capacitorjs.com/docs/apis/preferences
- Android App Links: https://developer.android.com/training/app-links
- iOS Universal Links: https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app
- Internal:
docs/architecture/authentication.md— current Clerk integration overview - Internal:
docs/operations/mobile-builds.md— APK signing and release flow - Internal failed-plan archive:
.claude/plans/i-m-having-a-couple-elegant-trinket.md— original Custom Tab + App Link plan that hit the cookie-jar wall - Internal source of truth for current native OAuth attempt (to be replaced):
ionic_frontend/src/composables/useNativeOAuth.ts - Internal Clerk JWT verifier (unchanged by this PRD):
rails_api/app/services/clerk_jwt_verifier.rb
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/.