Reading passport chips from React Native
In 2020 no library did this in a state you could ship. What it took: the protocol written once in Java, mirrored in Swift, behind a single interface exposed through a Turbo Module while Turbo Modules were still experimental.
A remote identity verification product for German financial institutions. The existing flow was seven steps of photographs, video and gestures, and a computer vision model deciding whether the document in front of the camera looked genuine. It worked, and it was still inference: the model believes this is a real passport.
The chip is a different kind of answer. It hands over data signed by the issuing state. Reading it moves the product 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, reading an NFC document chip from React Native was not available in any package you would put in front of a bank. Apple had only opened chip reading to third-party apps the year before. What existed was mostly native, mostly Android, mostly unmaintained. So the protocol had to be implemented natively and exposed to the JavaScript layer.
What the protocol asks of you
The first surprise for most people is that you cannot simply tap the passport. The chip will not answer until you prove you are physically holding the document, and it makes you prove it with what is printed on the page.
MRZ, from the printed page
└─ document number + date of birth + date of expiry, each with a check digit
└─ BAC / PACE — session keys derived from exactly that
└─ secure channel: every command encrypted and MACed, sequence counter
└─ DG1 machine-readable zone data
DG2 facial image
SOD signed hashes of every data group
└─ passive authentication:
verify the issuing country's signature,
then re-hash each group and compareBAC derives the session keys from the machine-readable zone, so the read order is: get the MRZ first, optically or typed, 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 do not 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 step most implementations 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 populate.
One interface, two native implementations
The protocol was worked out and written in Java for Android. The iOS version is that same logic rewritten in Swift with the same behaviour — not designed a second time. React Native saw one shared interface through a Turbo Module, with the two native implementations behind it.
That mirroring was deliberate. Two independent implementations of an ICAO reader will diverge on edge cases — an unusual data group, a document that only offers PACE, a check digit convention — and the result is that the same passport passes on one phone and fails on another. That is an unanswerable support ticket, and the user’s conclusion is that your app is broken, which it is.
On the JavaScript side none of this is visible. One call, a typed result, typed failures:
// the shape of it, in today's API terms
export interface Spec extends TurboModule {
readDocument(key: {
documentNumber: string
dateOfBirth: string // YYMMDD, from the MRZ
dateOfExpiry: string
}): Promise<{
mrz: string // DG1
portrait: string // DG2, base64
passiveAuth: 'valid' | 'failed' | 'unavailable'
}>
}Decoding the data that came off the chip was then the client’s own external API, not ours. Worth naming as a boundary: the module’s job ends at "here is what the chip said and whether the signature held".
Turbo Modules in 2020
They were the experimental part of React Native’s new architecture at the time — enabled 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 specific job. A chip read is a stateful conversation with a physical object in someone’s hand, it is latency sensitive, and it fails in a dozen distinguishable ways. A typed interface generated from one declaration, rather than 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 is not where the user thinks. Its position varies by phone model, and the passport chip is in the cover or the data page depending on the document. People hold the passport over the screen, because that is where the instructions are. The illustration has to show the back of the phone.
A read takes seconds, not milliseconds. Long enough that people move, and moving ends the session. The interface has to hold attention through the wait, and it has to distinguish "keep holding" 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 we would do differently now
Most of it. There are maintained libraries today, the new architecture is stable, and chip reading on iOS is ordinary. If the same task came in tomorrow we would reach for what exists and spend the time on the parts above, which have not improved.
The one decision we would repeat is writing the protocol once and mirroring it. Whatever the platform layer looks like, two independent readings of the same standard will eventually disagree about the same passport, and you will find out from a person who has already tried three times.
This came out of a remote KYC product for German financial institutions, where chip reading was added on top of a seven-step verification flow. Read the case study →