Skip to main content

Migration Guide: stytch-ios → stytch-mobile (iOS)

This guide covers what changed when moving from StytchCore (the original iOS-only SDK) to StytchConsumerSDK or StytchB2BSDK (the new Kotlin Multiplatform-backed SDK).

What Changed at a Glance

Old SDK (stytch-ios)New SDK (stytch-mobile)
PackageStytchCore / StytchUI from stytchauth/stytch-iosStytchConsumerSDK / StytchB2BSDK from stytchauth/stytch-ios-sdk
ClientStytchClient (global static)createStytchConsumer(configuration:) (instance-based)
ConfigurationStytchClient.configure(configuration:)createStytchConsumer(configuration:) — configure and create in one step
Auth stateStytchClient.sessions.onSessionChange (Combine publisher)stytch.authenticationStateFlow (async sequence)
Concurrencyasync/await, Combine publishers, and completion handlers (Sourcery-generated)async/await
DeeplinksStytchClient.handle(url:sessionDurationMinutes:)stytch.authenticate(url:sessionDurationMinutes:)
OAuth resultTwo-step: start() returns (token, url), then call authenticate(token:)One-step: start() returns the full auth response
Pre-built UIStytchUI / StytchUIClientNot provided — bring your own UI
Session migrationAutomatic — existing sessions are migrated on first launch

Installation

The Swift Package URL is now stytchauth/stytch-ios-sdk, and the product names have changed.

Before

// Xcode: Add Package → https://github.com/stytchauth/stytch-ios
// Product: StytchCore   (or StytchUI)
import StytchCore

After

Remove StytchCore and StytchUI from your target’s frameworks and add the new product:
// Xcode: Add Package → https://github.com/stytchauth/stytch-ios-sdk
// Product: StytchConsumerSDK   (Consumer / B2C apps)
// Product: StytchB2BSDK        (B2B apps)
import StytchConsumerSDK
// or:
import StytchB2BSDK
In Package.swift:
// Before
.product(name: "StytchCore", package: "stytch-ios"),

// After
.product(name: "StytchConsumerSDK", package: "stytch-ios-sdk"),
// or:
.product(name: "StytchB2BSDK", package: "stytch-ios-sdk"),
The -ObjC linker flag is still required. In your target’s Build Settings, confirm that Other Linker Flags contains -ObjC.

Configuration and Client Initialization

Before: global static + separate configure call

import StytchCore

// Configure first (typically in AppDelegate or App.init)
StytchClient.configure(configuration: .init(publicToken: "public-token-live-..."))

// Then use the global static anywhere
let response = try await StytchClient.otps.send(parameters: params)

After: factory function returns an instance

import StytchConsumerSDK

// Create once and store — App.init, a dependency container, or a top-level let
let stytch = createStytchConsumer(
    configuration: .init(publicToken: "public-token-live-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx")
)

// Then use the instance
let response = try await stytch.otp.sms.loginOrCreate(request: params)
The instance is a singleton internally — createStytchConsumer called again with the same token returns the same object. Storing it as a let constant or passing it through your dependency injection makes the dependency explicit rather than relying on a global.

Authentication State and Session Access

This is the most significant behavioral change. The old SDK spread session and user state across multiple Combine publishers. The new SDK gives you a single async sequence.

Before: Combine publishers

import Combine
import StytchCore

var subscriptions = Set<AnyCancellable>()

// Session changes
StytchClient.sessions.onSessionChange
    .receive(on: DispatchQueue.main)
    .sink { sessionInfo in
        switch sessionInfo {
        case let .available(session, lastValidatedAt):
            print("Active session: \(session.expiresAt)")
        case .unavailable:
            print("No session")
        }
    }
    .store(in: &subscriptions)

// Synchronous getters
let session = StytchClient.sessions.session
let sessionToken = StytchClient.sessions.sessionToken
let user = StytchClient.user.getSync()

After: async sequence

import StytchConsumerSDK

// Observe state changes
Task {
    for await state in stytch.authenticationStateFlow {
        switch onEnum(of: state) {
        case .authenticated(let s):
            let user = s.user
            let session = s.session
            let sessionToken = s.sessionToken
            let sessionJwt = s.sessionJwt
            // Update your UI
        case .unauthenticated:
            // Show login UI
        case .loading:
            // Restoring persisted session
        }
    }
}

// Synchronous access (the StateFlow always has a current value)
let currentState = stytch.authenticationStateFlow.value
If you were using Combine elsewhere in your app and want to bridge the async sequence back to a publisher, you can wrap it with AsyncStream or use the callback-based observer:
// Callback-based alternative (useful for bridging to Combine)
let cleanup = stytch.authenticationStateObserver { state in
    // called on every state change
}
// Call cleanup.stop() to unsubscribe

API Method Changes

Most method names are consistent, but the namespace and parameter type names have changed. The old SDK namespaced under StytchClient with nested type aliases (e.g., StytchClient.OTP.Parameters); the new SDK uses generated parameter types from the OpenAPI spec.

OTP

// Before
let params = StytchClient.OTP.Parameters(deliveryMethod: .sms(phoneNumber: "+15551234567"))
let response = try await StytchClient.otps.send(parameters: params)
let authParams = StytchClient.OTP.AuthenticateParameters(code: code, methodId: methodId)
let authResponse = try await StytchClient.otps.authenticate(parameters: authParams)

// After
let sendParams: OTPsSMSLoginOrCreateParameters = .init(phoneNumber: "+15551234567")
let sendResponse = try await stytch.otp.sms.loginOrCreate(request: sendParams)

let authParams: OTPsAuthenticateParameters = .init(token: code, methodId: methodId, sessionDurationMinutes: 30)
let authResponse = try await stytch.otp.authenticate(request: authParams)
Note: the old SDK had a single otps.send() that accepted the delivery method as an enum. The new SDK splits by channel: otp.sms, otp.email, otp.whatsapp.
// Before
let params = StytchClient.MagicLinks.Email.Parameters(
    email: "user@example.com",
    loginMagicLinkUrl: URL(string: "myapp://auth"),
    signupMagicLinkUrl: URL(string: "myapp://auth")
)
try await StytchClient.magicLinks.email.loginOrCreate(parameters: params)

// After
let params: MagicLinksByEmailLoginOrCreateParameters = .init(
    email: "user@example.com",
    loginMagicLinkUrl: "myapp://auth",
    signupMagicLinkUrl: "myapp://auth"
)
try await stytch.magicLinks.email.loginOrCreate(request: params)
Redirect URLs are now String rather than URL?.

Passwords

// Before
try await StytchClient.passwords.authenticate(
    parameters: .init(email: "user@example.com", password: "pw", sessionDurationMinutes: 30)
)

// After
let params: PasswordsAuthenticateParameters = .init(email: "user@example.com", password: "pw", sessionDurationMinutes: 30)
try await stytch.passwords.authenticate(request: params)

Session Management

// Before
try await StytchClient.sessions.authenticate(
    parameters: Sessions.AuthenticateParameters(sessionDurationMinutes: Minutes(rawValue: 30))
)
try await StytchClient.sessions.revoke(parameters: Sessions.RevokeParameters())

// After
let params: SessionsAuthenticateParameters = .init(sessionDurationMinutes: 30)
try await stytch.session.authenticate(request: params)
try await stytch.session.revoke()
sessionDurationMinutes is now a plain Int — the Minutes wrapper is gone.

Before

switch try await StytchClient.handle(url: url, sessionDurationMinutes: 5) {
case let .handled(response):
    switch response {
    case let .auth(r): print(r.session)
    case let .oauth(r): print(r.session)
    }
case .notHandled:
    break
case let .manualHandlingRequired(tokenType, token, _):
    // e.g., password reset
    break
}

After

let result = try await stytch.authenticate(url: url.absoluteString, sessionDurationMinutes: 30)
switch onEnum(of: result) {
case .authenticated:
    // User is now logged in
case .manualHandlingRequired(let status):
    // Password reset — store status.token, prompt for new password,
    // then call stytch.passwords.resetByEmail(...)
    let resetToken = status.token
case .unknownDeeplink:
    // Not a Stytch deeplink
}
The new authenticate() takes a String rather than a URL. Pass url.absoluteString from your onOpenURL or openURLContexts handler.

OAuth

The OAuth flow has been simplified. The old SDK returned an intermediate (token, url) tuple from start() that you then passed to authenticate(). The new SDK completes the entire flow — browser session, redirect handling, and token exchange — inside a single start() call.

Before

// Third-party (e.g., Google)
let configuration = StytchClient.OAuth.ThirdParty.WebAuthenticationConfiguration(
    loginRedirectUrl: URL(string: "myapp://login"),
    signupRedirectUrl: URL(string: "myapp://signup")
)
let (token, url) = try await StytchClient.oauth.google.start(configuration: configuration)
let response = try await StytchClient.oauth.authenticate(
    parameters: .init(token: token)
)

// Sign In With Apple
let response = try await StytchClient.oauth.apple.start(parameters: .init())

After

// Third-party (e.g., Google) — start() returns the full auth response
let params: OAuthStartParameters = .init(
    loginRedirectUrl: "myapp://login",
    signupRedirectUrl: "myapp://signup",
    sessionDurationMinutes: 30,
    oauthPresentationContextProvider: self  // ASPresentationAnchor provider
)
let response = try await stytch.oauth.google.start(startParameters: params)

// Sign In With Apple — same interface, no separate authenticate() call needed
let appleParams: OAuthStartParameters = .init()
let response = try await stytch.oauth.apple.start(startParameters: appleParams)
Redirect URLs are now String rather than URL?. The oauthPresentationContextProvider is the replacement for WebAuthenticationConfiguration.presentationContextProvider.

Concurrency: Combine and Completion Handlers

The old SDK used Sourcery to generate Combine and completion-handler variants of every async method. The new SDK does not generate these — it exposes async/await only. If your codebase relies heavily on Combine at call sites, you can bridge with:
// Wrapping an async call in a Future (one-shot)
let publisher = Future<OTPsAuthenticateResponse, Error> { promise in
    Task {
        do {
            let response = try await stytch.otp.authenticate(request: params)
            promise(.success(response))
        } catch {
            promise(.failure(error))
        }
    }
}
For continuous state observation (previously onSessionChange), use the authenticationStateObserver callback or bridge authenticationStateFlow with AsyncStream:
let stream = AsyncStream(ConsumerAuthenticationState.self) { continuation in
    let cleanup = stytch.authenticationStateObserver { state in
        continuation.yield(state)
    }
    continuation.onTermination = { _ in cleanup.stop() }
}

// Bridge to a Publisher
let publisher = stream.publisher

B2B SDK

If you used StytchB2BClient in the old SDK, the migration follows the same pattern. Import StytchB2BSDK and use createStytchB2B(configuration:):
import StytchB2BSDK

let stytch = createStytchB2B(
    configuration: .init(publicToken: "public-token-live-...")
)
The B2B auth state uses B2BAuthenticationState with .authenticated (carrying member, memberSession, organization), .unauthenticated, and .loading cases.

Pre-Built UI

StytchUI and StytchUIClient are not available in the new SDK. The new SDK is intentionally headless — you have complete control over every pixel of your authentication experience, with no constraints on layout, styling, navigation, or branding. Your existing screens remain yours; you just wire them to the SDK methods directly.

Automatic Session Migration

There’s nothing you need to do. On first launch after upgrading, the new SDK automatically reads and decrypts your users’ existing sessions from the old SDK’s UserDefaults store (StytchEncryptedUserDefaults suite) and migrates them into the new SDK’s storage format. Users who were logged in will remain logged in.