Skip to content

@clerk/expo: email link sign-in never completes on iOS — nothing forwards the callback URL to Clerk.handle(url) #9450

Description

@csandman

This report was written by Claude, but the behavior and the source references were verified manually on real devices.

Email link (magic link) sign-in completes on Android but silently fails on iOS when using the prebuilt <AuthView /> from @clerk/expo/native. The app reopens on the callback URL and no session is created.

Filing without a minimal reproduction — apologies. This was found and verified on a production Expo app, and the analysis below is from reading the shipped SDK source, so I wanted to get it in front of you rather than sit on it. Happy to build a standalone repro if that's what it takes to get it looked at. Publishable key omitted for the same reason (production instance).

What happens

  1. User starts email link sign-in from <AuthView /> in an Expo app.
  2. The email opens Clerk's hosted page, which verifies and shows "Return to App".
  3. That button deep-links back to the app on the bundle identifier scheme, e.g. com.myapp.bundleid://callback?flow_id=…&approval_token=….
  4. Android: the session is created and the user is signed in.
  5. iOS: the app comes to the foreground, but no session is created and the user is still signed out. No error surfaces.

Why (as far as I can tell)

clerk-ios completes the flow only from the callback's flow_id + approval_tokenMagicLink.swift has no polling fallback — and its single intake path is Clerk.handle(_ url:). In AuthView.swift, the only caller is SwiftUI's .onOpenURL:

.onOpenURL { url in
  Task {
    do { try await clerk.handle(url) }
    catch { self.error = error }
  }
}

.onOpenURL is driven by the SwiftUI app lifecycle. Expo/React Native apps use the UIKit app delegate, so URLs arrive via application(_:open:options:) and that modifier never fires for the hosted SwiftUI view — nothing ever calls Clerk.handle(url).

Nothing in @clerk/expo bridges the gap: searching packages/expo on main returns zero references to onOpenURL, openURL, handleURL, or onNewIntent. Android is unaffected because clerk-android registers its own clerk:// intent filter and receives the callback natively — hence the platform split.

Expected behavior

@clerk/expo forwards inbound URLs to the native SDK on iOS the way clerk-android already does on Android, so email link sign-in completes on both platforms.

An ExpoAppDelegateSubscriber implementing application(_:open:options:) and calling Clerk.shared.handle(url) looks like it would cover it — Clerk.shared is public and already configured by ClerkNativeBridge, and returning false would leave the URL available to React Native's Linking, with clerk-ios's own URLHandlingCoordinator deduping any double handling. There's also a com.clerk.expo.native-sdk.did-configure notification that could handle a cold launch arriving before the SDK is configured.

Workaround

A local Expo module doing exactly the above. Worth noting for anyone else hitting this: the app also needs a route for the callback URL, or expo-router renders +not-found on top of it — on Android that means a "page does not exist" screen over a session that was just created successfully.

Environment

  System:
    OS: macOS 26.5.2
    CPU: (12) arm64 Apple M2 Pro
  Binaries:
    Node: 24.15.0
    npm: 11.12.1
    pnpm: 11.1.2
  npmPackages:
    @clerk/expo: ^3.7.1 => 3.7.1
    @clerk/shared: 4.25.0 => 4.25.0
    expo: ~57.0.12 => 57.0.12
    expo-router: ~57.0.12 => 57.0.12
    react: 19.2.3 => 19.2.3
    react-native: 0.86.2 => 0.86.2

Also reproduced on @clerk/expo@4.2.7, and packages/expo on main has the same gap.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions