Skip to main content
This guide covers how to implement social login (OAuth) in your mobile application. Make sure you have configured a social login provider on your backend before proceeding. Pick your platform once: tabs across this section stay in sync.

OAuth login flow

On the web the flow is three separate steps — redirect, callback, and finalize. On mobile the SDK runs all three for you in a single call:
  1. PresentloginWithOAuth opens the provider’s login page in a system web session (ASWebAuthenticationSession on iOS, a Chrome Custom Tab on Android)
  2. Callback — the provider redirects back to your app’s custom URL scheme with a challenge_token, which the SDK captures
  3. Finalize — the SDK exchanges the challenge_token for a session and persists the tokens in the platform’s secure store
The call returns a FinalizeOAuthLoginResult: either the user is logged in, or an OTP step is required. The second case only happens when the provider has verify_email enabled and the IdP returns an email it has not verified — see Verify email via OTP below.
The entire flow is protected by PKCE (Proof Key for Code Exchange). The SDK generates a unique code_verifier and code_challenge pair for each login attempt, and the challenge_token can only be finalized once. This protects against:
  • Authorization code interception — a stolen challenge_token is useless without the code_verifier, which never leaves the device
  • Replay attacks — the challenge_token is invalidated after a single use
  • Cross-site request forgery (CSRF) — the server validates that the finalize request matches the original authorization request
Every attempt carries its own verifier, so concurrent logins never share state. Once finalized, the session’s tokens are bound to the device with DPoP.

Platform setup

The provider redirects back to your app through a custom URL scheme (e.g. myapp://oauth-callback) — not an https URL. Passing an http/https redirect throws an invalid-configuration error before any network call. The redirect URI must also be allowlisted in your provider’s configuration.
No additional setup is required. The system web session captures the redirect scheme directly, so you don’t need to register it in Info.plist.Social login lives in the PreludeAuthSocial product — an opt-in module so apps that skip social pull no extra code. Add it alongside PreludeAuth in Package.swift:
.product(name: "PreludeAuthSocial", package: "apple-auth-sdk"),

Sign in with a provider

Call loginWithOAuth with a provider and your redirect URI. The SDK presents the provider’s page, handles the callback, and finalizes the session. Supported providers are google, apple, microsoft, github, okta, and facebook.
import PreludeAuthSocial

let result = try await client.loginWithOAuth(
    OAuthLoginOptions(
        provider: .google,
        redirectURI: URL(string: "myapp://oauth-callback")!
    )
)
Set prefersEphemeralSession: true to start every login from a clean session with no shared browser cookies.

Handle the result

On success the SDK persists the access and refresh tokens in the platform’s secure store and returns the outcome. Handle the two cases: the user is logged in, or an email OTP step is required. A dismissed page surfaces as a cancellation — usually swallowed rather than shown as an error.
import PreludeAuth
import PreludeAuthSocial

do {
    let result = try await client.loginWithOAuth(
        OAuthLoginOptions(
            provider: .google,
            redirectURI: URL(string: "myapp://oauth-callback")!
        )
    )
    switch result {
    case .loggedIn(let user):
        // User is now authenticated.
    case .otpRequired(let challenge, let email):
        // Provider email unverified — a code was sent to `email`.
        // Route to your OTP screen; see "Verify email via OTP".
    }
} catch PreludeAuthError.cancelled {
    // User dismissed the page — not an error.
} catch PreludeAuthError.conflict {
    // Another social login is already in progress.
} catch PreludeAuthError.invalidConfiguration {
    // redirectURI is not a custom scheme.
} catch PreludeAuthError.expiredChallengeToken {
    // Login expired — restart the flow.
} catch PreludeAuthError.rateLimited {
    // Too many attempts.
}

Verify email via OTP

When the provider config has verify_email enabled and the IdP returns an unverified email, loginWithOAuth does not finalize the login. Instead it returns an OTP-required result carrying a resumable challenge and the email the code was sent to. The code has already been sent. Show your OTP screen, then pass the code the user enters — along with the challenge from the result — to checkOAuthEmailOTP. On success it returns the authenticated user. A wrong code leaves the challenge valid so the user can retry.
// From the .otpRequired case above:
do {
    let user = try await client.checkOAuthEmailOTP(
        codeFromUser,
        resuming: challenge
    )
    // User is now authenticated.
} catch PreludeAuthError.invalidOTPCode {
    // Wrong code — the challenge is still valid, let the user retry.
}

Present your own web session

loginWithOAuth presents the provider page for you. If you’d rather present it yourself — a custom web view or your own browser session — use the lower-level pair instead. initiateOAuthLogin returns the provider’s authorization URL; present it, capture the challenge_token delivered to your redirect URI, then redeem it with finalizeOAuthLogin, which returns the same result type as loginWithOAuth.
import PreludeAuth

let context = try await client.initiateOAuthLogin(
    InitiateOAuthLoginOptions(
        provider: .google,
        redirectURI: "myapp://oauth-callback"
    )
)
// Present context.authorizationURL, capture the challenge_token, then:
let result = try await client.finalizeOAuthLogin(context, challengeToken: token)
Each example presents provider buttons, completes the login, and drops into an OTP screen when the provider’s email needs verifying. Use the redirect scheme you configured in Platform setup.
Add the PreludeAuthSocial product, then replace ContentView.swift with:
ContentView.swift
import SwiftUI
import PreludeAuth
import PreludeAuthSocial

private let appID = "YOUR_APP_ID"
private let redirectURI = URL(string: "myapp://oauth-callback")!

enum Step { case login, otp, done }

@MainActor
final class OAuthModel: ObservableObject {
    let client = try! PreludeAuthClient(
        endpoint: .custom("https://\(appID).session.prelude.dev")
    )

    @Published var step: Step = .login
    @Published var user: PreludeUser?
    @Published var error: String?

    private var challenge: OAuthEmailChallenge?

    func signIn(_ provider: OAuthProvider) async {
        error = nil
        do {
            let result = try await client.loginWithOAuth(
                OAuthLoginOptions(provider: provider, redirectURI: redirectURI)
            )
            switch result {
            case .loggedIn(let user):
                self.user = user
                step = .done
            case .otpRequired(let challenge, _):
                self.challenge = challenge
                step = .otp
            }
        } catch PreludeAuthError.cancelled {
            // Dismissed — leave state untouched.
        } catch {
            self.error = "Something went wrong. Please try again."
        }
    }

    func verify(_ code: String) async {
        guard let challenge else { return }
        error = nil
        do {
            user = try await client.checkOAuthEmailOTP(code, resuming: challenge)
            step = .done
        } catch PreludeAuthError.invalidOTPCode {
            error = "Wrong code. Please try again."
        } catch {
            self.error = "Something went wrong. Please try again."
        }
    }
}

struct ContentView: View {
    @StateObject private var model = OAuthModel()
    @State private var code = ""

    private let providers: [OAuthProvider] = [.google, .apple, .microsoft, .github, .okta, .facebook]

    var body: some View {
        VStack(spacing: 12) {
            switch model.step {
            case .login:
                ForEach(providers, id: \.self) { provider in
                    Button("Continue with \(provider.rawValue.capitalized)") {
                        Task { await model.signIn(provider) }
                    }
                    .buttonStyle(.borderedProminent)
                }
            case .otp:
                TextField("Enter code", text: $code)
                    .keyboardType(.numberPad)
                    .textFieldStyle(.roundedBorder)
                Button("Verify Code") {
                    Task { await model.verify(code) }
                }
                .buttonStyle(.borderedProminent)
            case .done:
                Text("Logged in").font(.headline)
                Text(model.user?.profile.userID ?? "—").font(.caption.monospaced())
            }
            if let error = model.error {
                Text(error).foregroundStyle(.red).font(.caption)
            }
        }
        .padding()
    }
}