Skip to content

iOS SDK ​

The SecondFactor Swift library is the client half of hosted verification for iOS apps. It opens our verification page in an ASWebAuthenticationSession sheet and returns the result when the page sends the user back. It is one Swift file with no dependencies.

It runs on the user's device. It never calls the secondfactor.ai API, and never holds your API key, a phone number or a code. Your server creates and confirms the session with the Node.js or Python SDK.

Act on the result on your server. What this library reads from a URL is not proof of anything, because anyone can craft a URL. Your server's verifySession call is the proof.

Requirements ​

  • iOS 17.4 or later for SecondFactor.verify, which uses ASWebAuthenticationSession with an https:// callback. parseReturn and isVerificationPageURL work from iOS 16.
  • Xcode 15.3 or later. The file compiles in Swift 5 and Swift 6 language modes.

Install ​

The library is not published to CocoaPods or tagged for release. Its source is on GitHub at secondfactor/secondfactor-ios. It is one short file, so you can also treat it as an example and adapt it. Pick one way in.

Copy the file. Download SecondFactor.swift and drag it into your app target in Xcode. Nothing else is needed, and you do not write import SecondFactor. To update, replace the file.

Swift Package Manager, from git. In Xcode, choose File → Add Package Dependencies…, enter https://github.com/secondfactor/secondfactor-ios, and set the dependency rule to the main branch. In a Package.swift:

swift
.package(url: "https://github.com/secondfactor/secondfactor-ios", branch: "main"),

Then add the SecondFactor product to your target and import SecondFactor where you use it.

Set up ​

  1. In the dashboard, under Settings → Hosted verification, add your return origin, such as https://app.example.com. Custom URL schemes are not accepted.

  2. Give your app the Associated Domains capability with webcredentials:app.example.com.

  3. Serve https://app.example.com/.well-known/apple-app-site-association listing your app under webcredentials:

    json
    { "webcredentials": { "apps": ["TEAMID.com.example.yourapp"] } }

Without steps 2 and 3, iOS does not hand the return back to your app, and the sheet does not close by itself.

How it fits together ​

  1. Your server creates a session, stores its sid and gives the page's url to your app.
  2. Your app calls SecondFactor.verify. We send the code, the user types it on our page, and the sheet closes when we send them back to your return URL.
  3. Your app posts the returnToken that verify returned to your server.
  4. Your server calls verifySession with the sid it stored and that return token, once, and acts on the answer.

Usage ​

A complete verification in SwiftUI, from asking your server for the page to telling the user how it went. MyAPI stands for your app's own client for your server, with one endpoint for step 1 and one for step 3. If you copied the file instead of adding the package, leave out import SecondFactor. If your app's deployment target is iOS 17.4 or later, leave out the @available lines.

swift
import SecondFactor
import SwiftUI

/// Your app's client for your own server.
protocol MyAPI: Sendable {
    /// Asks your server to call `createSession`, and returns the page's `url`.
    func startVerification() async throws -> URL

    /// Hands the return token to your server, which calls `verifySession`.
    func finishVerification(returnToken: String) async throws
}

/// The same `https://` URL your server passes to `createSession`.
let returnURL = URL(string: "https://app.example.com/verified")!

/// Runs one verification and returns a message to show the user.
@available(iOS 17.4, *)
@MainActor
func verifyPhone(api: some MyAPI) async throws -> String {
    let url = try await api.startVerification()

    let back: SecondFactor.VerificationReturn
    do {
        back = try await SecondFactor.verify(url: url, returnURL: returnURL)
    } catch SecondFactorError.canceled {
        return "Verification canceled."
    } catch SecondFactorError.couldNotStart {
        return "The verification page could not be opened."
    }
    // Any other error means the app and the server disagree about a URL, or
    // the system failed. That is a bug to fix, not something the user can act
    // on, so it reaches the caller.

    guard let token = back.returnToken else {
        // The user ran out of attempts, or the session expired.
        return "Your number was not verified. Please try again."
    }
    try await api.finishVerification(returnToken: token)
    return "Your number is verified."
}

/// A button that runs `verifyPhone` and shows the result.
@available(iOS 17.4, *)
struct VerifyPhoneButton<API: MyAPI>: View {
    let api: API

    @State private var busy = false
    @State private var message = ""

    var body: some View {
        VStack {
            Button("Verify phone number") {
                Task {
                    busy = true
                    defer { busy = false }
                    do {
                        message = try await verifyPhone(api: api)
                    } catch {
                        print("Verification failed: \(error)") // Send this to your error reporting.
                        message = "Something went wrong. Please try again."
                    }
                }
            }
            .disabled(busy)
            Text(message)
        }
    }
}

The button is disabled while a verification is open, so only one sheet is shown at a time.

SecondFactor.verify ​

swift
static func verify(url: URL, returnURL: URL) async throws -> VerificationReturn

Opens the hosted page in an ASWebAuthenticationSession and returns when the page sends the user to returnURL. Call it on the main actor.

ParamTypeDescription
urlURLThe url your server got when it created the session. Anything else throws .notAVerificationPage and is not opened.
returnURLURLThe same https:// URL your server passed as ReturnUrl.

The sheet uses an ephemeral browser session, so it shares no cookies with Safari and keeps none afterwards.

SecondFactor.parseReturn ​

swift
static func parseReturn(_ url: URL) -> VerificationReturn?

Reads sf_session_id and sf_return_token from any URL, or returns nil when there is no session ID. verify calls it for you. Call it yourself when the return reaches your app another way, such as a universal link delivered to onOpenURL.

SecondFactor.isVerificationPageURL ​

swift
static func isVerificationPageURL(_ url: URL) -> Bool

Returns whether url has the shape of a hosted page: https:// (plain http:// is accepted only for localhost, 127.0.0.1, [::1] and *.localhost), no user name or password, and the session's token in its fragment.

VerificationReturn ​

FieldTypeDescription
sessionIdStringsf_session_id. Which stored session the user came back from. Always confirm the sid your server stored, never this one.
returnTokenString?sf_return_token. Present only when the user verified; nil when they canceled on the page, ran out of attempts or the session expired. It works once.

Errors ​

verify throws a SecondFactorError:

CaseMeaning
.notAVerificationPageurl is not a hosted page. Pass the server's url unchanged.
.invalidReturnURLreturnURL is not https:// with a host.
.canceledThe user closed the sheet. Nothing was verified.
.missingReturnParametersThe page returned without sf_session_id.
.couldNotStartThe sheet could not be shown, for example with no window.

Any other error from ASWebAuthenticationSession is thrown unchanged.

Example app ​

The repository's Examples/SignInDemo is a SwiftUI app with a complete password-then-phone sign-in. It runs with no server, simulating the hosted page, or against your own server.