Verification

NFC verification

The native-only ePassport / eID chip read — read-on-device, validate-on-server; per-platform setup, standalone APIs, and the flow-delegated path.

Reading an ePassport / eID chip over NFC is the one capability a WebView cannot do — it needs the device NFC radio. So ThirdFactor uses a hybrid: the hosted flow keeps handling document capture, liveness, and face match in the WebView, and the native SDK supplies the NFC read over the capability bridge. This mirrors how Didit and other providers split the work — read on device, validate on server. The SDK reads raw data groups; the backend does the cryptography.

Info

NFC is a native-only capability, present in the iOS, Android, and Flutter SDKs. The web SDK has no NFC module — on the web an NFC_VERIFICATION step falls through to server-side manual review.

End-to-end sequence

Applicant scans document (WebView)  ──►  MRZ OCR'd by the engine

        ▼  flow reaches the NFC step AND window.__thirdfactorHost.capabilities includes "nfc"
Flow  ── nfc.scan { documentNumber, dateOfBirth, dateOfExpiry } ─►  Native SDK
        │                                                             │
        │                         BAC/PACE + read DG1/DG2/EF.SOD      │
Flow  ◄── resolveCommand(id, { ok, data:{ sod_b64, dg1_b64, dg2_b64 }}) ─┘

        ▼  POST /api/verify/<token>/nfc/
Backend ── passive authentication ──►  resolve NFC_VERIFICATION module

Web (iframe/popup) hosts advertise no capabilities, so the flow keeps the server-side fallback: NFC_VERIFICATION → manual review (nfc_not_available).

What each layer does

Native SDK (device) — reads, does not validate

  • Advertises capabilities: ["nfc"] via window.__thirdfactorHost only when the device NFC radio is available.
  • Handles the nfc.scan command: derives the BAC key from the MRZ (documentNumber + dateOfBirth + dateOfExpiry) or a CAN for PACE, opens secure messaging, and reads DG1 (MRZ), DG2 (face), and EF.SOD (the signed hash set). Returns them base64 — raw bytes only.
  • Also exposes a standalone read(...) API for apps that want NFC outside the hosted flow.
PlatformLibraryStandalone entry point
iOSNFCPassportReader (ThirdFactorKYCNFC product)ThirdFactorNFC.read(mrzKey:dataGroups:)
AndroidJMRTD + SCUBA + BouncyCastle (ThirdFactorNfc)ThirdFactorNfc.read(activity, accessKey, …)
Flutterdmrtd (ThirdFactorNfc)ThirdFactorNfc.read(documentNumber:…)

Backend (server) — validates, decides

POST /api/verify/<token>/nfc/ runs passive authentication:

Passive authentication (tamper check)

Parse EF.SOD (CMS SignedData → LDSSecurityObject), recompute each uploaded DG's hash, and compare to the SOD's stored value. Any mismatch ⇒ declined (the chip data was altered after signing).

MRZ read-back

Parse DG1 and compare the document number to the document the applicant already scanned — catches a genuine chip lifted from another booklet. A mismatch caps the verdict at review.

Trust chain (optional)

Verifying the SOD signer chains to a trusted CSCA requires an ICAO masterlist. Set NFC_CSCA_DIR to a masterlist directory to enable it. Without it, integrity-verified chips resolve to in_review, not approved — integrity alone doesn't prove authenticity (an attacker can forge a chip with internally-consistent hashes); only the CSCA chain does.

Decision mapping:

ConditionNFC_VERIFICATION result
a DG hash doesn't match the SOD (tamper)declined
SOD unparseable / no DGs to checkin_review
integrity OK, no CSCA masterlist configuredin_review
integrity OK + CSCA chain verifiedapproved

Warning

The client NFC read is advisory like every other step. The NFC_VERIFICATION module result feeds the session decision, which you confirm server-side via the signed webhook or GET /v3/session/<id>/decision/.

Two integration paths

Flow-delegated (automatic)

If your workflow includes an NFC_VERIFICATION step and you launch the hosted flow with the SDK on an NFC device, the chip read just works — the flow sends nfc.scan with the MRZ it OCR'd, the SDK reads and forwards, and the backend validates. On Android and Flutter this needs no extra code beyond verify. On iOS you opt in by setting a native command handler:

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 keeps the server-side fallback (NFC step → manual review). The delegation is entirely driven by the capability handshake: the SDK advertises ["nfc"] only when the radio is on, and the flow calls nfc.scan only when the host advertised it.

Standalone (read outside the flow)

Each mobile SDK exposes a standalone read for custom UI, offline enrolment, or re-verification. All three return the same nfc.scan data shape — base64 DGs plus aa / chipAuth { performed, success } — which you POST to your own backend for passive authentication.

import ThirdFactorKYCNFC

let key = MRZKey(documentNumber: "L898902C3", dateOfBirth: "740812", dateOfExpiry: "120415") // YYMMDD
let result = await ThirdFactorNFC.read(mrzKey: key, dataGroups: [.DG1, .DG2, .SOD])
if result.success, let data = result.data {
    upload(data.asDictionary())  // { sod_b64, dg1_b64, dg2_b64, aa, chipAuth }
} else {
    // result.errorCode ∈ user_cancelled | tag_lost | wrong_key | unsupported | nfc_disabled | timeout
}

Signatures (verified against sdks/ios/Sources/):

static func read(mrzKey: MRZKey, dataGroups: [DataGroupId] = [.DG1, .DG2, .SOD]) async -> NfcReadResult
static func commandHandler() -> NativeCommandHandler   // pass to VerifyOptions.nativeCommandHandler

MRZKey(rawKey: String)                                             // precomputed BAC key
MRZKey(documentNumber: String, dateOfBirth: String, dateOfExpiry: String)  // dates YYMMDD; SDK computes check digits

// NfcReadResult { success: Bool; data: NfcData?; errorCode: String?; errorMessage: String? }
// NfcData.asDictionary() -> [String: Any]

Note

CAN-only PACE is not wired up in this build — supply the MRZ fields (or mrzKey). Including .DG15 also runs Active Authentication.

Per-platform setup

NFC needs entitlements/permissions and a dependency the core SDK doesn't pull in by default. Full details live in each SDK page; the essentials:

Data groups

The chip is an ICAO-9303 Logical Data Structure. Only a handful of elementary files matter for KYC; the SDKs default to the three the backend needs and let you request more:

FileWhat it holdsDefault read?
EF.SOD (SOD)The signed hash set over all data groups — the anchor for passive authentication.
DG1The MRZ, mirrored from the chip (name, document number, dates, nationality).
DG2The encoded face image — used for face matching against the live selfie.
DG11Additional personal details (optional).❌ (request explicitly)
DG12Additional document details (optional).❌ (request explicitly)
DG15Active Authentication public key — reading it lets the chip prove it's genuine, not cloned.⚠️ iOS/Flutter attempt AA when present; request explicitly to force it
EF.COM (COM)The list of data groups actually present on the chip.❌ (read best-effort for capability detection)

Error codes

The nfc.scan result (and the standalone read failures) use a stable set of codes — branch on the code, never the message:

codeMeaningTypical UX
user_cancelledUser dismissed the NFC sheetLet them retry
tag_lostChip moved away mid-read"Hold still against the phone"
wrong_keyMRZ/CAN didn't match the chip's access keyRe-capture the MRZ
unsupportedDocument/chip type not supported (e.g. CAN-only on iOS, mrzKey-only on Android/Flutter)Fall back to manual path
nfc_disabledNFC turned off at the OS levelPrompt to enable NFC
timeoutNo chip presented within the window (bridge default 180 s)Try again

Troubleshooting

Warning

Every scan returns wrong_key. BAC/PACE derives the access key from documentNumber + dateOfBirth + dateOfExpiry, so any of those being wrong — or the dates not being YYMMDD — fails key agreement. In the flow-delegated path the MRZ comes from the engine's OCR, so a persistent wrong_key almost always means a poor photo-page scan; re-capture it. For eIDs with no MRZ, use the CAN path (NfcAccessKey.Can on Android, canNumber on Flutter) — iOS does not support CAN-only in this build and returns unsupported.

Warning

Integrity-verified chips come back in_review instead of approved. That's expected until you configure a CSCA masterlist. Passive authentication proves the chip data wasn't altered, but only the CSCA signer chain proves it's authentic — an attacker can forge a chip whose hashes are internally consistent. Set NFC_CSCA_DIR to an ICAO masterlist directory on the backend to promote integrity-verified reads to approved.

Warning

NFC works in a dev build but fails in TestFlight / release. On iOS the NFC entitlement, the select-identifiers array, and the provisioning profile are all bound to a specific bundle ID + team. If you changed the bundle identifier (e.g. white-labeling) without regenerating the App ID's NFC capability and profile in the Apple Developer portal, chip reads fail in release even though document capture and liveness — which need no entitlement — keep working.

Warning

The SDK never receives an nfc.scan command. The flow only sends it when the host advertised the nfc capability. Check that: the device radio is on (the SDK advertises [] when it's off), the workflow actually contains an NFC_VERIFICATION step, and — on iOS — you set options.nativeCommandHandler = ThirdFactorNFC.commandHandler(). Without all three, the flow silently keeps its server-side fallback.

FAQ