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
| iOS | 15+ (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. |
| Concurrency | async/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 products — ThirdFactorKYC (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| Field | Type | Default | Notes |
|---|---|---|---|
title | String | "Identity verification" | Navigation-bar title of the presented flow. |
onEvent | ((VerificationEvent) -> Void)? | nil | Low-level lifecycle stream: .ready / .completed / .error / .close. |
expectedOrigin | String? | origin of url | Only override behind a proxy. Include the scheme, e.g. "https://kyc.acme.com". |
autoClose | Bool | true | false keeps the flow's own result screen until the user taps Done. |
nativeCommandHandler | NativeCommandHandler? | nil | Host-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
}status | When |
|---|---|
.approved | flow reported approved |
.declined | flow reported declined |
.inReview | in_review / review / manual_review / pending, or any unknown terminal status (conservative) |
.cancelled | flow reported abandoned, or the user dismissed (Cancel / swipe) before a terminal decision |
.error | an 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)
| Member | Signature | Notes |
|---|---|---|
verify(from:url:options:) | static func verify(from: UIViewController, url: URL, options: VerifyOptions = .init()) async -> VerificationResult | async/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
| Field | Type | Default | Notes |
|---|---|---|---|
title | String | "Identity verification" | Navigation-bar title of the presented flow. |
onEvent | ((VerificationEvent) -> Void)? | nil | Lifecycle stream: .ready / .completed / .error / .close. |
expectedOrigin | String? | origin of url | Only override behind a proxy. Include the scheme, e.g. "https://kyc.acme.com". |
autoClose | Bool | true | false keeps the flow's own result screen until the user taps Done. |
nativeCommandHandler | NativeCommandHandler? | nil | Set ThirdFactorNFC.commandHandler() to enable native NFC. |
Result types
| Type | Shape | Notes |
|---|---|---|
VerificationResult | struct { status: VerificationStatus; sessionId: String?; rawStatus: String?; error: ThirdFactorError? } | Sendable, Equatable. |
VerificationStatus | enum { .approved, .declined, .inReview, .cancelled, .error } | Sendable, Equatable. |
ThirdFactorError | struct { code: String; message: String } | Conforms to Error and LocalizedError (errorDescription returns message). Branch on code. |
VerificationEvent | enum { .ready, .completed(status: String, sessionId: String?), .error(code: String, message: String?), .close } | Sendable, Equatable. status on .completed is the raw server string. |
SwiftUI
| Member | Signature | Notes |
|---|---|---|
ThirdFactorVerification | struct 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+)
| Member | Signature | Notes |
|---|---|---|
read(mrzKey:dataGroups:) | static func read(mrzKey: MRZKey, dataGroups: [DataGroupId] = [.DG1, .DG2, .SOD]) async -> NfcReadResult | Standalone chip read, independent of the hosted flow. |
commandHandler() | static func commandHandler() -> NativeCommandHandler | Pass 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? } | errorCode ∈ user_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.