Verification

NFC verification

Read the ePassport / eID chip on the device, validate it on the server — BAC/PACE, DG1/DG2/SOD and passive authentication.

The NFC_VERIFICATION module reads the contactless chip embedded in an e-passport or biometric ID and validates its cryptographically-signed contents. Chip data is signed by the issuing authority, so a successful read is far stronger evidence than an OCR'd photo — and it yields a high-quality chip portrait for face match.

Reading the chip needs the device NFC radio, which a WebView cannot reach. So NFC is a native-SDK-only capability: the iOS, Android and Flutter SDKs read the chip, and the Obsidian backend does the cryptography. The pattern is read on device, validate on server — the SDK returns raw data groups, the backend performs passive authentication.

Info

On a web host (iframe / popup) the flow advertises no NFC capability, so NFC_VERIFICATION falls back to a server-side manual review (nfc_not_available) rather than blocking the applicant. Native hosts advertise capabilities: ["nfc"] and run the real chip read.

Why NFC beats OCR

OCR reads printed ink and a photographed portrait — both of which a forger can alter on a fake booklet. NFC reads the chip the issuing authority personalised, whose contents are protected by a signature (the SOD) that a tamper would break:

OCR (document capture)NFC (chip read)
Source of truthPrinted page + photoIssuer-signed chip
Portrait qualityPhoto of a photoFull-resolution DG2 image ✅
Tamper evidenceForensics heuristics ⚠️Cryptographic hash check ✅
Proof of issuerNoneCSCA trust chain (when configured) ✅
Works in a WebView❌ (needs native radio)

NFC doesn't replace document capture — the OCR'd MRZ is what derives the chip key and what the chip is cross-checked against. They run in sequence.

End-to-end flow

Document scanned in the flow

The applicant photographs the document; the engine OCRs the MRZ (document number, date of birth, date of expiry).

Native SDK reads the chip

When the flow reaches the NFC step and the host advertises the nfc capability, it issues nfc.scan over the capability bridge with the MRZ fields. The SDK derives the BAC key from the MRZ (or a CAN for PACE), opens secure messaging, and reads DG1 (MRZ), DG2 (face) and EF.SOD (the signed hash set) — returning them base64, raw bytes only.

Server validates

The client posts the data groups to POST /api/verify/<token>/nfc/, which runs passive authentication and resolves the NFC_VERIFICATION module.

The SDKs also expose a standalone readNfc(...) API for reading a chip outside the hosted flow. Per-SDK setup (iOS entitlements, Android/Flutter permissions, deps) is in the NFC SDK guide. Under the hood: iOS NFCPassportReader, Android JMRTD + BouncyCastle, Flutter dmrtd.

What the SDK sends the server

The native read returns three base64 blobs, which the client posts unchanged:

{
  "sod_b64": "<EF.SOD, base64>",
  "dg1_b64": "<DG1 (MRZ), base64>",
  "dg2_b64": "<DG2 (face), base64>"
}

What the server validates

The backend (nfc_service.passive_authenticate) does three things:

  1. Passive authentication (tamper check). Parse EF.SOD (CMS SignedDataLDSSecurityObject), recompute the hash of each uploaded data group (SHA-1 or SHA-256, per the SOD), and compare it to the SOD's stored value. Any mismatch means the chip data was altered after signing.
  2. MRZ read-back. Parse DG1 and compare the document number to the document the applicant already scanned — this catches a genuine chip lifted from another booklet. A mismatch caps the verdict at review.
  3. Trust chain (optional). Verifying that the SOD signer chains to a trusted Country Signing CA requires an ICAO CSCA masterlist. Set NFC_CSCA_DIR to a masterlist directory to enable it.

Warning

Integrity alone does not prove authenticity. Without a configured CSCA masterlist, an integrity-verified chip resolves to review, not approved — an attacker can forge a chip whose internal hashes are self-consistent; only the CSCA chain proves the chip was signed by a real issuing authority.

Decision mapping

ConditionNFC_VERIFICATION result
A data-group hash doesn't match the SOD (tamper)declined
SOD unparseable / no data groups to checkin_review ⚠️
MRZ read-back mismatch (chip vs scanned document)in_review ⚠️
Integrity OK, no CSCA masterlist configuredin_review ⚠️
Integrity OK and CSCA chain verifiedapproved
Host has no NFC capability (web fallback)in_review (nfc_not_available) ⚠️

Common failure reasons

If the native read fails, the SDK surfaces a stable code the flow can act on — the read never silently succeeds:

CodeWhat happenedWhat to do
wrong_keyThe BAC/PACE key derived from the MRZ (or CAN) didn't open the chip — usually a mistyped or mis-OCR'd document number, DOB or expiry.Re-confirm the MRZ fields and retry; offer manual entry.
tag_lostThe phone lost contact with the chip mid-read (moved too soon).Ask the applicant to hold the phone still against the document.
nfc_disabledNFC is off (or unavailable) on the device.Prompt to enable NFC, or fall back to document-only.
user_cancelledThe applicant dismissed the native scan sheet.Re-prompt or let them skip to review.
timeoutNo chip detected within the read window.Reposition the phone over the chip (passports: rear cover; ID cards: centre) and retry.
Unsupported documentThe document has no readable chip (a non-biometric ID or an unsupported booklet).Fall through to the document-only path; the module reviews rather than blocks.

A wrong_key after a good OCR read most often means the MRZ was misread — the chip key is derived from exactly those three fields, so a single wrong digit fails key derivation. Let the applicant correct the prefilled MRZ before retrying.

Device support

NFC reading depends on the device having an NFC radio and the OS exposing it to apps: modern iPhones (via Core NFC / NFCPassportReader) and most Android phones with NFC hardware. Web browsers expose no passport-chip API, which is why the hosted flow only offers the NFC step on native hosts that advertise the nfc capability and otherwise falls through to the server-side review.

FAQ