Verification

iOS SDK

ThirdFactorKYC (SwiftPM) — open the hosted flow in a WKWebView from UIKit or SwiftUI, with optional native ePassport NFC.

ThirdFactorKYC opens the hosted verification flow in a WKWebView, listens for lifecycle events over the shared bridge, and resolves one typed VerificationResult. An optional NFC product (ThirdFactorKYCNFC) reads ePassport / eID chips natively.

Warning

The client result is advisory. Grant access or move money only after your backend confirms the decision from the signed webhook (identity.kyc.session.approved / .declined / .review / .expired) or GET /v3/session/<id>/decision/.

Requirements

iOS15+ (the SwiftPM package floor).
Core (ThirdFactorKYC)Pure Swift, no third-party dependencies (WKWebView only). UIKit + SwiftUI entry points.
NFC (ThirdFactorKYCNFC, optional)iOS 15+, a device with NFC (iPhone 7+). Depends on MIT-licensed NFCPassportReader 2.1.0+. The Simulator has no NFC.
Concurrencyasync/await and completion-handler APIs; the verify API is @MainActor.

Installation

Swift Package Manager. In Xcode: File → Add Package Dependencies… and point at the repo, or add it to Package.swift:

dependencies: [
    .package(url: "https://github.com/your-org/thirdfactor-obsidian.git", from: "1.0.0"),
],
targets: [
    .target(name: "YourApp", dependencies: [
        .product(name: "ThirdFactorKYC", package: "thirdfactor-obsidian"),
        // add only if you need native NFC chip reading:
        .product(name: "ThirdFactorKYCNFC", package: "thirdfactor-obsidian"),
    ]),
]

The package exposes two library productsThirdFactorKYC (core) and ThirdFactorKYCNFC (NFC). Link the NFC product only if you read chips; it keeps the core dependency-free.

Permissions

The flow captures the camera (document + selfie/liveness) and may use the microphone. iOS requires purpose strings in Info.plist or the app is rejected / the prompt never appears:

<key>NSCameraUsageDescription</key>
<string>We use the camera to verify your identity.</string>
<key>NSMicrophoneUsageDescription</key>
<string>We use the microphone during liveness checks.</string>

The SDK auto-grants the in-WebKit camera/mic permission; these strings back the one-time OS prompt. NFC needs additional configuration — see NFC verification.

Quick start

import ThirdFactorKYC

// sessionURL is the `url` your backend got from POST /v3/session/
func startKYC(sessionURL: URL) {
    Task {
        let result = await ThirdFactor.verify(from: self, url: sessionURL)
        if result.status == .approved { showSuccess() }   // then confirm server-side
    }
}

Integration methods

All three variants resolve the same VerificationResult.

let result = await ThirdFactor.verify(from: self, url: sessionURL)
switch result.status {
case .approved:  showSuccess()
case .declined:  showFailure()
case .inReview:  showPending()
case .cancelled: break                 // user closed the flow
case .error:     showError(result.error)
}

Configuration

Pass a VerifyOptions to any verify(...) variant:

public static func verify(
    from presenter: UIViewController,
    url: URL,
    options: VerifyOptions = .init()
) async -> VerificationResult
FieldTypeDefaultNotes
titleString"Identity verification"Navigation-bar title of the presented flow.
onEvent((VerificationEvent) -> Void)?nilLow-level lifecycle stream: .ready / .completed / .error / .close.
expectedOriginString?origin of urlOnly override behind a proxy. Include the scheme, e.g. "https://kyc.acme.com".
autoCloseBooltruefalse keeps the flow's own result screen until the user taps Done.
nativeCommandHandlerNativeCommandHandler?nilHost-side native capability (NFC). Set ThirdFactorNFC.commandHandler() to enable native NFC.
var options = VerifyOptions(title: "Verify identity", autoClose: true)
let result = await ThirdFactor.verify(from: self, url: sessionURL, options: options)

Result handling

verify(...) never throws — it always resolves a VerificationResult:

public struct VerificationResult {
    let status: VerificationStatus   // .approved .declined .inReview .cancelled .error
    let sessionId: String?           // echoed from the flow when available
    let rawStatus: String?           // raw server status, e.g. "abandoned"
    let error: ThirdFactorError?     // set only when status == .error
}
statusWhen
.approvedflow reported approved
.declinedflow reported declined
.inReviewin_review / review / manual_review / pending, or any unknown terminal status (conservative)
.cancelledflow reported abandoned, or the user dismissed (Cancel / swipe) before a terminal decision
.erroran error event or a load/transport failure — carries error.code + error.message

ThirdFactorError carries a stable code (branch on it, never on message): invalid_session, expired, load_error, unknown (extendable).

var options = VerifyOptions()
options.onEvent = { event in print("event:", event) }   // ready/completed/error/close

let result = await ThirdFactor.verify(from: self, url: sessionURL, options: options)
switch result.status {
case .approved:  proceed()                              // confirm server-side first
case .declined:  showFailure()
case .inReview:  showPending()
case .cancelled: break
case .error:
    if result.error?.code == "expired" { reMintSession() }
    else { showError(result.error) }
}

NFC verification

Some documents carry an ICAO-9303 contactless chip (the eMRTD in a biometric passport, or an eID card). Reading it over NFC yields a cryptographically signed copy of the holder's data — stronger than an OCR/photo. A WebView cannot reach NFC, so this is a native capability: ThirdFactorKYCNFC reads the chip and hands raw bytes to the flow (or to your app directly).

Warning

Passive authentication — verifying the SOD signer chain against the CSCA masterlist — is done server-side; the SDK only reads and forwards raw bytes. All ICAO-9303 crypto (BAC, PACE, secure messaging, Active/Chip Authentication) is handled by NFCPassportReader; this SDK does not hand-roll any of it.

Setup — capability, entitlement, Info.plist

Add the Near Field Communication Tag Reading capability in Xcode (Signing & Capabilities → + Capability). That adds the entitlement:

<!-- YourApp.entitlements -->
<key>com.apple.developer.nfc.readersession.formats</key>
<array>
    <string>TAG</string>
</array>

Plus Info.plist keys:

<key>NFCReaderUsageDescription</key>
<string>We read your passport/ID chip to verify your identity.</string>

<key>com.apple.developer.nfc.readersession.iso7816.select-identifiers</key>
<array>
    <string>A0000002471001</string>          <!-- eMRTD (ePassport) — REQUIRED -->
    <string>A00000045645444C2D3031</string>  <!-- (optional) some national eIDs -->
    <string>A0000002472001</string>          <!-- (optional) eID/CardAccess -->
</array>

A0000002471001 (the ICAO eMRTD application) is required to read passports; the others are optional and only needed for the specific eID cards you support.

Flow-delegated NFC (automatic)

If your workflow includes an NFC_VERIFICATION step, pass a NativeCommandHandler so the flow drives the chip read itself. The SDK advertises ["nfc"] to the flow only when the device can read NFC, the flow sends an nfc.scan command with the MRZ it already OCR'd, and the result is posted back automatically — no extra app code:

import ThirdFactorKYC
import ThirdFactorKYCNFC

var options = VerifyOptions()
if #available(iOS 15, *) {
    options.nativeCommandHandler = ThirdFactorNFC.commandHandler()
}
let result = await ThirdFactor.verify(from: self, url: sessionURL, options: options)

Without a handler (or on a device without NFC), the flow transparently keeps its server-side fallback (NFC step → manual review).

Standalone read

Read a chip directly, independent of the hosted flow. Provide the three MRZ fields (the SDK computes the ICAO check digits) or a precomputed key:

import ThirdFactorKYCNFC

func scanPassport() async {
    let key = MRZKey(
        documentNumber: "L898902C3",   // from the MRZ
        dateOfBirth:    "740812",      // YYMMDD
        dateOfExpiry:   "120415"       // YYMMDD
    )
    // Default data groups: [.DG1, .DG2, .SOD].
    let result = await ThirdFactorNFC.read(mrzKey: key, dataGroups: [.DG1, .DG2, .SOD])

    if result.success, let data = result.data {
        let payload = data.asDictionary()  // ["sod_b64": …, "dg1_b64": …, "dg2_b64": …, "aa": …, "chipAuth": …]
        upload(payload)                    // POST to your backend for passive auth
    } else {
        // result.errorCode ∈ user_cancelled | tag_lost | wrong_key |
        //                     unsupported | nfc_disabled | timeout
        print("NFC failed:", result.errorCode ?? "", result.errorMessage ?? "")
    }
}

Public surface:

@available(iOS 15, *)
public enum ThirdFactorNFC {
    static func read(mrzKey: MRZKey,
                     dataGroups: [DataGroupId] = [.DG1, .DG2, .SOD]) async -> NfcReadResult
    static func commandHandler() -> NativeCommandHandler   // for the hosted flow
}

public struct MRZKey {
    init(rawKey: String)
    init(documentNumber: String, dateOfBirth: String, dateOfExpiry: String)  // dates YYMMDD
}

NfcReadResult exposes success, data: NfcData?, errorCode, errorMessage. NfcData.asDictionary() produces the exact nfc.scan data shape (sod_b64, dg1_b64, dg2_b64, optional dg11_b64/dg12_b64/com_b64, plus aa / chipAuth { performed, success }).

Note

CAN-only PACE is not wired up in this build. The nfc.scan handler rejects a canNumber-only request with unsupported; supply the MRZ fields (or mrzKey). Including .DG15 in dataGroups also runs Active Authentication.

For the full NFC architecture (device vs. server split, passive-auth decision mapping), see the NFC deep-dive.

Complete API reference

Every public symbol in ThirdFactorKYC and ThirdFactorKYCNFC, verified against sdks/ios/Sources/:

ThirdFactor (core, @MainActor)

MemberSignatureNotes
verify(from:url:options:)static func verify(from: UIViewController, url: URL, options: VerifyOptions = .init()) async -> VerificationResultasync/await variant. @discardableResult. Never throws.
verify(from:url:options:completion:)static func verify(from: UIViewController, url: URL, options: VerifyOptions = .init(), completion: @escaping (VerificationResult) -> Void)Completion-handler variant; fires exactly once, on the main thread, after dismissal.

VerifyOptions

FieldTypeDefaultNotes
titleString"Identity verification"Navigation-bar title of the presented flow.
onEvent((VerificationEvent) -> Void)?nilLifecycle stream: .ready / .completed / .error / .close.
expectedOriginString?origin of urlOnly override behind a proxy. Include the scheme, e.g. "https://kyc.acme.com".
autoCloseBooltruefalse keeps the flow's own result screen until the user taps Done.
nativeCommandHandlerNativeCommandHandler?nilSet ThirdFactorNFC.commandHandler() to enable native NFC.

Result types

TypeShapeNotes
VerificationResultstruct { status: VerificationStatus; sessionId: String?; rawStatus: String?; error: ThirdFactorError? }Sendable, Equatable.
VerificationStatusenum { .approved, .declined, .inReview, .cancelled, .error }Sendable, Equatable.
ThirdFactorErrorstruct { code: String; message: String }Conforms to Error and LocalizedError (errorDescription returns message). Branch on code.
VerificationEventenum { .ready, .completed(status: String, sessionId: String?), .error(code: String, message: String?), .close }Sendable, Equatable. status on .completed is the raw server string.

SwiftUI

MemberSignatureNotes
ThirdFactorVerificationstruct ThirdFactorVerification: UIViewControllerRepresentable (iOS 14+)init(isPresented: Binding<Bool>, url: URL, options: VerifyOptions = .init(), onResult: @escaping (VerificationResult) -> Void)
.thirdFactorVerification(...)func thirdFactorVerification(isPresented: Binding<Bool>, url: URL, options: VerifyOptions = .init(), onResult: @escaping (VerificationResult) -> Void) -> some View (iOS 14+)View extension; resets isPresented to false once a result is delivered.

ThirdFactorNFC (the ThirdFactorKYCNFC product, iOS 15+)

MemberSignatureNotes
read(mrzKey:dataGroups:)static func read(mrzKey: MRZKey, dataGroups: [DataGroupId] = [.DG1, .DG2, .SOD]) async -> NfcReadResultStandalone chip read, independent of the hosted flow.
commandHandler()static func commandHandler() -> NativeCommandHandlerPass to VerifyOptions.nativeCommandHandler to wire flow-delegated NFC.
MRZKey.init(rawKey:)init(rawKey: String)Precomputed BAC key string.
MRZKey.init(documentNumber:dateOfBirth:dateOfExpiry:)init(documentNumber: String, dateOfBirth: String, dateOfExpiry: String)Dates YYMMDD; the SDK computes ICAO check digits.
NfcReadResult{ success: Bool; data: NfcData?; errorCode: String?; errorMessage: String? }errorCodeuser_cancelled | tag_lost | wrong_key | unsupported | nfc_disabled | timeout.
NfcData.asDictionary()func asDictionary() -> [String: Any]Produces the exact nfc.scan data shape: sod_b64, dg1_b64, dg2_b64, optional dg11_b64/dg12_b64/com_b64, aa, chipAuth.

Full end-to-end example

A minimal SwiftUI screen that fetches a session from your own backend endpoint, then runs verification:

import SwiftUI
import ThirdFactorKYC
import ThirdFactorKYCNFC

struct KYCScreen: View {
    @State private var showKYC = false
    @State private var sessionURL: URL?
    @State private var statusText = "Not verified"

    var body: some View {
        VStack(spacing: 16) {
            Text(statusText)
            Button("Verify identity") {
                Task { await startVerification() }
            }
        }
        .thirdFactorVerification(
            isPresented: $showKYC,
            url: sessionURL ?? URL(string: "about:blank")!,
            options: makeOptions()
        ) { result in
            handle(result)
        }
    }

    /// Calls YOUR backend, which in turn calls POST /v3/session/ with your
    /// secret API key. Never call the Obsidian API directly from the app.
    func startVerification() async {
        guard let url = await fetchSessionURL() else { return }
        sessionURL = url
        showKYC = true
    }

    func fetchSessionURL() async -> URL? {
        var request = URLRequest(url: URL(string: "https://api.acme.com/kyc/session")!)
        request.httpMethod = "POST"
        guard let (data, _) = try? await URLSession.shared.data(for: request),
              let json = try? JSONSerialization.jsonObject(with: data) as? [String: String],
              let urlString = json["url"] else { return nil }
        return URL(string: urlString)
    }

    func makeOptions() -> VerifyOptions {
        var options = VerifyOptions(title: "Verify your identity", autoClose: true)
        if #available(iOS 15, *) {
            options.nativeCommandHandler = ThirdFactorNFC.commandHandler()
        }
        options.onEvent = { event in print("[kyc]", event) }
        return options
    }

    func handle(_ result: VerificationResult) {
        switch result.status {
        case .approved:
            statusText = "Approved (pending server confirmation)"
        case .declined:
            statusText = "Declined"
        case .inReview:
            statusText = "Under review"
        case .cancelled:
            statusText = "Cancelled"
        case .error:
            statusText = "Error: \(result.error?.code ?? "unknown")"
        }
    }
}

Troubleshooting

Warning

The camera permission prompt never appears, and the flow shows a camera error. NSCameraUsageDescription (and NSMicrophoneUsageDescription for liveness) must be present in Info.plist before the app is built — iOS silently denies the permission (no prompt, no crash) if the purpose string is missing, rather than throwing. Add both keys even if you think your workflow skips liveness; the flow decides at runtime which capture steps run.

Warning

nfc.scan always returns unsupported, or the NFC sheet never appears. The most common cause is the Near Field Communication Tag Reading capability/entitlement not being attached to the exact App ID you're building and signing with — NFC entitlements are provisioning-profile-bound. Double-check: (1) the capability is added in Xcode Signing & Capabilities for the target you're actually running, (2) com.apple.developer.nfc.readersession.iso7816.select-identifiers includes A0000002471001, (3) you're on a physical iPhone 7+ — the Simulator has no NFC hardware at all and will always fail differently (typically hangs or reports unsupported).

Warning

verify(...) seems to hang or never presents anything. ThirdFactor is @MainActor-isolated — calling it from a background actor/thread without awaiting correctly, or presenting from a presenter that is not currently in the view hierarchy (e.g. already dismissed, or a detached UIViewController), silently fails to present. Always call verify(from:) with a presenter that is on-screen at call time, from the main actor.

Warning

Bundle identifier mismatches break NFC in TestFlight/App Store builds that worked in a dev build. The NFC entitlement, the select-identifiers array, and the provisioning profile are all tied to a specific bundle ID + team. If you changed the bundle identifier (e.g. white-labeling the app) without regenerating the App ID's NFC capability and profile in the Apple Developer portal, chip reads will fail in release builds even though the rest of the flow (document capture, liveness) works fine, since those don't need any entitlement.

FAQ

How it works

verify(...) loads the flow with ?embed=1&sdk=ios appended. In embed mode the flow reports lifecycle over window.webkit.messageHandlers.thirdfactor. The SDK registers a WKScriptMessageHandler named thirdfactor (load-bearing — hard-coded in the flow), validates the sender frame's origin against the session URL, keeps the WebView pinned to the trusted origin, auto-grants in-WebKit camera/mic, and resolves exactly once. See the bridge protocol for the full contract.