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 moduleWeb (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"]viawindow.__thirdfactorHostonly when the device NFC radio is available. - Handles the
nfc.scancommand: 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.
| Platform | Library | Standalone entry point |
|---|---|---|
| iOS | NFCPassportReader (ThirdFactorKYCNFC product) | ThirdFactorNFC.read(mrzKey:dataGroups:) |
| Android | JMRTD + SCUBA + BouncyCastle (ThirdFactorNfc) | ThirdFactorNfc.read(activity, accessKey, …) |
| Flutter | dmrtd (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:
| Condition | NFC_VERIFICATION result |
|---|---|
| a DG hash doesn't match the SOD (tamper) | declined |
| SOD unparseable / no DGs to check | in_review |
| integrity OK, no CSCA masterlist configured | in_review |
| integrity OK + CSCA chain verified | approved |
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
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:
| File | What it holds | Default read? |
|---|---|---|
EF.SOD (SOD) | The signed hash set over all data groups — the anchor for passive authentication. | ✅ |
DG1 | The MRZ, mirrored from the chip (name, document number, dates, nationality). | ✅ |
DG2 | The encoded face image — used for face matching against the live selfie. | ✅ |
DG11 | Additional personal details (optional). | ❌ (request explicitly) |
DG12 | Additional document details (optional). | ❌ (request explicitly) |
DG15 | Active 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:
code | Meaning | Typical UX |
|---|---|---|
user_cancelled | User dismissed the NFC sheet | Let them retry |
tag_lost | Chip moved away mid-read | "Hold still against the phone" |
wrong_key | MRZ/CAN didn't match the chip's access key | Re-capture the MRZ |
unsupported | Document/chip type not supported (e.g. CAN-only on iOS, mrzKey-only on Android/Flutter) | Fall back to manual path |
nfc_disabled | NFC turned off at the OS level | Prompt to enable NFC |
timeout | No 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.