Skip to main content

Document Reading - NFC Implementation

The NFC module enables reading secure data from electronic travel documents (ePassports and eID cards) using the device's NFC reader. It handles the cryptographic protocols (BAC and PACE) required to establish a secure channel and verify document authenticity.


Architecture Overview

The iOS implementation follows a delegate-based pattern that integrates seamlessly with UIKit and SwiftUI:

  • Delegate Pattern: The SDK uses delegates to communicate reading progress and results, making it easy to integrate with any UI framework.

  • Lifecycle Management: NFC reading sessions are managed automatically, with proper handling of device capabilities and user permissions.

  • Error Handling: Comprehensive error handling for common scenarios like missing NFC support, tag loss, and authentication failures.


Core Components

MBDocumentReadingServiceNfc

The main class responsible for NFC reading operations. It requires a license and implements the delegate pattern for result handling.

import MobaiNfc
import CoreNFC

class NFCReadingService: MBDocumentReadingServiceNfcDelegate {
private lazy var nfcReadingExecutor: MBDocumentReadingServiceNfc = {
let executor = MBDocumentReadingServiceNfc(license: license)
executor.delegate = self
return executor
}()

func startReading(with results: MBResults) {
_ = nfcReadingExecutor.execute(result: results)
}
}

MBDocumentReadingServiceNfcDelegate

The delegate protocol that handles NFC reading events:

protocol MBDocumentReadingServiceNfcDelegate {
func onReadingStarted()
func onReadingFinished(result: MBNfcReadingResult)
func onReadingError(message: String)
}
MethodDescription
onReadingStarted()Called when NFC reading begins
onReadingFinished(result:)Called when reading completes successfully with the extracted document data
onReadingError(message:)Called when an error occurs during reading

Data Models

MBResults

Contains the MRZ-derived keys needed to establish the secure channel with the document chip:

struct MBResults {
let documentNumber: String
let dateOfExpiry: String // Format: YYMMDD
let dateOfBirth: String // Format: YYMMDD
}

MBNfcReadingResult

The result object containing all extracted data from the NFC chip:

struct MBNfcReadingResult {
let image: UIImage
let travelDocument: TravelDocument // Contains all data groups
}

Complete Integration Example

Step 1: License Loading

Create a helper to load the license from the app bundle:

import Foundation

enum LicenseHelper {
static func loadLicense() throws -> Data {
guard let url = Bundle.main.url(forResource: "iengine", withExtension: "lic") else {
throw LicenseError.missingLicenseFile
}
do {
return try Data(contentsOf: url)
} catch {
throw LicenseError.unreadableLicenseFile(underlying: error)
}
}
}

enum LicenseError: LocalizedError {
case missingLicenseFile
case unreadableLicenseFile(underlying: Error)

var errorDescription: String? {
switch self {
case .missingLicenseFile:
return "Missing iengine.lic in the app bundle."
case .unreadableLicenseFile(let underlying):
return "Unable to read iengine.lic (\(underlying.localizedDescription))."
}
}
}

Step 2: NFC Reading Flow

Implement a complete flow that handles document capture, MRZ extraction, and NFC reading:

import UIKit
import SwiftUI
import CoreNFC
import MobaiNfc
import MobaiDocument

final class OCRNfcCaptureFlow: MBDocumentReadingServiceNfcDelegate {
private let navigationController: UINavigationController
private let license: Data
private var captureRouter: DocumentCaptureRouter?
private var lastResults: MBResults?

private lazy var nfcReadingExecutor: MBDocumentReadingServiceNfc = {
let executor = MBDocumentReadingServiceNfc(license: license)
executor.delegate = self
return executor
}()

init(navigationController: UINavigationController, license: Data) {
self.navigationController = navigationController
self.license = license
}

func start() {
let configuration = MBDocumentCaptureOptions(isMrzReadingEnabled: true)
let captureRouter = DocumentCaptureRouter(
title: "Capture",
navigationController: navigationController,
configuration: configuration,
license: license
)
self.captureRouter = captureRouter
captureRouter.start()
captureRouter.onCaptureResult = { [weak self] result in
self?.handleCaptureResult(result)
}
}

// MARK: - MBDocumentReadingServiceNfcDelegate

func onReadingStarted() {
// Optional: Show loading indicator
}

func onReadingFinished(result: MBNfcReadingResult) {
// Display results in a SwiftUI view
let vc = UIHostingController(
rootView: MBNfcReadingResultView(result: result)
)
navigationController.pushViewController(vc, animated: true)
}

func onReadingError(message: String) {
let alert = UIAlertController(
title: "Something went wrong",
message: message,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))

// Optionally add retry if we have previous results
if let lastResults {
alert.addAction(UIAlertAction(title: "Retry", style: .default) { [weak self] _ in
_ = self?.startNfcReading(with: lastResults)
})
}
navigationController.topViewController?.present(alert, animated: true)
}

// MARK: - Private Methods

private func startNfcReading(with results: MBResults) {
_ = nfcReadingExecutor.execute(result: results)
}

private func ensureNfcAvailable() -> Bool {
guard NFCTagReaderSession.readingAvailable else {
navigationController.topViewController?.presentOkAlert(
title: "NFC unavailable",
message: "NFC is not available on this device. Use a physical iPhone with NFC support."
)
return false
}
return true
}

private func createResult(from captureResult: MBDocumentCaptureResult) -> MBResults? {
guard let machineReadableZone = captureResult.machineReadableZone,
let travelDocumentType = captureResult.documentType else {
navigationController.topViewController?.presentOkAlert(
title: "Unable to read machine readable zone from document.",
message: ""
) { [weak self] in
self?.navigationController.popViewController(animated: true)
}
return nil
}

return MBResults(
documentNumber: MBDcoumentResultFactory.getDocumentNumber(
machineReadableZone: machineReadableZone,
travelDocumentType: travelDocumentType
),
dateOfExpiry: MBDcoumentResultFactory.getDateOfExpiry(
machineReadableZone: machineReadableZone,
travelDocumentType: travelDocumentType
),
dateOfBirth: MBDcoumentResultFactory.getDateOfBirth(
machineReadableZone: machineReadableZone,
travelDocumentType: travelDocumentType
)
)
}
}

Step 3: Handling Document Capture Results

Add this method inside OCRNfcCaptureFlow to connect document capture to NFC reading:

private func handleCaptureResult(_ result: MBDocumentCaptureResult) {
guard let mbResults = createResult(from: result) else { return }
guard ensureNfcAvailable() else { return }

lastResults = mbResults

// Check for master list (optional, for verification)
guard Bundle.main.url(forResource: "master_list", withExtension: "pem") != nil else {
navigationController.topViewController?.presentOkAlert(
title: "Missing master list",
message: "Add master_list.pem to the app bundle to enable passport NFC verification. NFC reading will continue without verification.",
onOk: { [weak self] in
self?.startNfcReading(with: mbResults)
}
)
return
}

startNfcReading(with: mbResults)
}

SwiftUI Integration

Result Display View

Create a SwiftUI view to display NFC reading results:

import SwiftUI
import MobaiNfc

struct MBNfcReadingResultView: View {
private let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
return encoder
}()

let result: MBNfcReadingResult

var body: some View {
ScrollView {
VStack(spacing: 16) {
Image(uiImage: result.image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: 300)

if let data = try? encoder.encode(result.travelDocument),
let description = String(data: data, encoding: .utf8) {
Text(description)
.font(.system(.footnote, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
} else {
Text("No Description Available")
.italic()
.padding()
}
}
.padding()
.navigationTitle("Result")
}
.background(Color(.systemBackground).ignoresSafeArea())
}
}

SwiftUI Entry Point

Bridge SwiftUI with UIKit navigation:

import SwiftUI

struct DocumentOCRNfcEntryView: View {
var body: some View {
if #available(iOS 16.0, *) {
content
.toolbar(.hidden, for: .navigationBar)
} else {
content
.navigationBarHidden(true)
}
}

private var content: DocumentOCRNfcFlowView {
DocumentOCRNfcFlowView()
}
}

struct DocumentOCRNfcFlowView: UIViewControllerRepresentable {
@Environment(\.dismiss) private var dismiss

func makeUIViewController(context: Context) -> UINavigationController {
let navigationController = UINavigationController(rootViewController: makeRootController())
navigationController.delegate = context.coordinator
return navigationController
}

func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {
context.coordinator.onDismiss = { dismiss() }
context.coordinator.startIfNeeded(in: uiViewController)
}

func makeCoordinator() -> Coordinator {
Coordinator()
}

final class Coordinator: NSObject, UINavigationControllerDelegate {
private var hasStarted = false
private var flow: OCRNfcCaptureFlow?
var onDismiss: (() -> Void)?
private var didStartFlow = false

func startIfNeeded(in navigationController: UINavigationController) {
guard !hasStarted else { return }
hasStarted = true

guard let license = loadLicense(in: navigationController) else { return }

let flow = OCRNfcCaptureFlow(navigationController: navigationController, license: license)
self.flow = flow
flow.start()
didStartFlow = true
}

private func loadLicense(in navigationController: UINavigationController) -> Data? {
do {
return try LicenseHelper.loadLicense()
} catch {
navigationController.topViewController?.presentOkAlert(
title: "License missing",
message: error.localizedDescription
)
return nil
}
}

func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
guard didStartFlow, navigationController.viewControllers.count == 1 else { return }
onDismiss?()
}
}

private func makeRootController() -> UIViewController {
let root = UIViewController()
root.view.backgroundColor = .systemBackground
return root
}
}

Error Handling

Common Error Scenarios

ErrorCauseSolution
"NFC unavailable"Device doesn't support NFC or NFC is disabledCheck NFCTagReaderSession.readingAvailable before starting
"Tag Lost"Device moved away from document during readingRetry the reading operation
"Authentication failed"Invalid MRZ keysVerify document number, date of birth, and expiry date are correct
"License missing"License file not found in bundleEnsure iengine.lic is added to the app target

Retry Logic

Implement retry functionality for transient errors:

func onReadingError(message: String) {
let alert = UIAlertController(
title: "Something went wrong",
message: message,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))

if let lastResults {
alert.addAction(UIAlertAction(title: "Retry", style: .default) { [weak self] _ in
_ = self?.startNfcReading(with: lastResults)
})
}
navigationController.topViewController?.present(alert, animated: true)
}

Master List (Optional)

For passive authentication and document verification, you can include a master list of CSCA (Country Signing Certificate Authority) certificates:

  1. Add master_list.pem to your app bundle
  2. The SDK will automatically use it for verification if present
  3. If missing, NFC reading will continue without verification

Best Practices

  1. Check NFC Availability: Always verify NFCTagReaderSession.readingAvailable before attempting to read
  2. Handle Permissions: Ensure NFC usage description is in Info.plist
  3. Store Last Results: Keep the last MBResults to enable retry functionality
  4. User Feedback: Provide clear feedback during the reading process
  5. Error Recovery: Implement retry logic for transient errors like tag loss

Sample Code

For complete working examples and reference implementations, see the iOS SDK Samples repository on GitHub.