Reading a passport's NFC chip from React Native
In 2020 there was no usable library for it. A native module in Java, the same logic ported to Swift, and one shared interface through a Turbo Module.
In 2020 I worked on remote identity verification for a German financial institution. Customers went through six randomized steps in front of the camera, and a computer vision model judged whether the document they held looked genuine. It worked, and it was still inference: the model believed it was looking at a real passport.
The chip gives a different kind of answer. It hands over data signed by the state that issued the document. Reading it moves the check from an estimate to a proof, which is why it was worth doing even though nothing existed to do it with.
Why not a library
In 2020 no package could read a document's NFC chip from React Native in a state you would put in front of a bank. Apple had opened chip reading to third-party apps only the year before. What existed was mostly native, mostly for Android and mostly unmaintained. So I implemented the protocol natively and made it available to the JavaScript layer.
What the protocol asks of you
The first surprise for most people is that you can't simply tap the passport. The chip won't answer until you prove you're physically holding the document, and it makes you prove it with what is printed on the page.
1MRZ, from the printed page2 └─ document number + date of birth + date of expiry, each with a check digit3 └─ BAC / PACE: session keys derived from exactly that4 └─ secure channel: every command encrypted and MACed, sequence counter5 └─ DG1 machine-readable zone data6 DG2 facial image7 SOD signed hashes of every data group8 └─ passive authentication:9 verify the issuing country's signature,10 then re-hash each group and compareBAC derives the session keys from the machine-readable zone, so the read order is fixed: get the MRZ first, from the camera or typed in, then talk to the chip. PACE is the newer mechanism with the same idea and much better key agreement, resistant to the offline attack that BAC's low-entropy key allows. Which one you get depends on the document, so a real implementation supports both and negotiates.
After that everything runs over a secure channel with a sequence counter, which has a practical consequence: if the phone loses the field halfway, the session is over. You don't resume, you start again, and the interface has to say so without making the user feel accused.
Passive authentication is the step that matters, and the easiest one to skip. The document security object holds a hash of every data group, signed by the country that issued the passport. Verifying that signature and re-hashing what you read is the difference between reading a chip and trusting one. Skip it and you have a convenient data-entry shortcut that a forger can fill in.
One interface, two native implementations
I worked the protocol out and wrote it in Java for Android. The iOS version is the same logic ported to Swift with the same behavior, not designed a second time. React Native saw one shared interface through a Turbo Module, with the two native implementations behind it.
Porting instead of writing it again was deliberate. Two independent implementations of an ICAO reader will diverge on edge cases, such as an unusual data group or a check digit convention. Then the same passport passes on one phone and fails on another. Nobody can answer that support ticket, and the user concludes that your app is broken, which it is.
On the JavaScript side none of this is visible. One call, a typed result, typed failures:
1// the shape of it, in today's API terms2export interface Spec extends TurboModule {3 readDocument(key: {4 documentNumber: string5 dateOfBirth: string // YYMMDD, from the MRZ6 dateOfExpiry: string7 }): Promise<{8 mrz: string // DG19 portrait: string // DG2, base6410 }>11}An external service decoded the data that came off the chip, and my part ended at reading it. That boundary is worth naming: the module's job ended at "here is what the chip said". Checking the signature was not part of it.
Turbo Modules in 2020
At the time they were the experimental part of React Native's new architecture: registered by hand, thinly documented and years away from a stable release. Choosing them for a product going into a financial institution was early adoption in the literal sense.
What made it defensible is that the alternative was worse for this job. A chip read is a stateful conversation with a physical object in someone's hand. It is sensitive to latency, and it fails in a dozen distinguishable ways. A typed interface generated from one declaration, instead of loosely typed messages passed across the old bridge, is the right shape for that even when the tooling around it is immature.
The parts nobody warns you about
The antenna isn't where the user thinks. Its position varies by phone model, and the passport chip sits in the cover or in the data page, depending on the document. People hold the passport over the screen, because that's where the instructions are. The illustration has to show the back of the phone.
A read takes seconds, not milliseconds. That is long enough for people to move, and moving ends the session. The interface has to hold attention through the wait, and it has to tell "keep holding" apart from "start again".
The failure taxonomy is the feature. Wrong MRZ key, chip out of range, document type not supported, signature verification failed: four different problems, four different things the person should do next, and only one of them is their fault. Collapsing them into "Could not read document" turns a solvable moment into an abandoned application.
What I'd do differently now
Most of it. There are maintained libraries today, the new architecture is stable, and reading a chip on iOS is ordinary. If the same task came in tomorrow, I'd use what exists and spend the time on the parts above, which haven't gotten easier.
I'd also test the two implementations differently. I ported the Java code to Swift by hand and checked the port by testing it. I should have built a shared set of protocol test vectors, known inputs with their expected outputs, and run both implementations against it. Testing that two implementations behave the same is not the same as testing that they are correct.
The one decision I'd repeat is writing the protocol once and porting it. Whatever the platform layer looks like, two independent readings of the same standard will eventually disagree about the same passport, and you'll hear about it from a person who has already tried three times.
Writing
If you’re working on something similar, send us the details.
The first call takes 30 minutes, and you’re under no obligation.