Some banking apps verify a customer's identity by reading the chip in their ID card or passport over NFC. I built the NFC identity-verification Android SDK for such an onboarding flow; there is a short technical summary in the NFC section of my work page. The goal was an SDK we understood line by line, so that when a read failed we knew where to look.
This post contains no internal code. Everything here is public: ICAO Doc 9303, ISO/IEC 7816-4 and BSI TR-03110, and how they map onto Android's NFC APIs. The standards are well documented. Most of the work is getting them to behave on real devices.
What's on the chip
Chip-based ID cards and passports follow ICAO Doc 9303. The chip holds a small file system called the LDS (Logical Data Structure). In LDS1, these are the files a typical verification flow cares about:
- EF.COM: the LDS version and the list of data groups present. It isn't covered by the SOD signature, so treat it as a hint. The trusted list of data groups comes from EF.SOD.
- DG1: the MRZ data, the same content as the machine-readable lines printed on the document (name, document number, nationality, date of birth, expiry).
- DG2: the facial image, wrapped in a biometric data block. It is usually the largest file you read. The image can be JPEG or JPEG 2000, and Android's
BitmapFactorycan't decode JPEG 2000, so plan for a decoder. - EF.SOD: the Document Security Object, a CMS SignedData structure that holds a hash of each data group, signed by the issuing state's Document Signer.
- DG14 / DG15: public keys for Chip Authentication and Active Authentication, present only if the chip supports them.
DG3 (fingerprints) and DG4 (iris) are protected by Extended Access Control and require terminal certificates issued by the state, so a regular app can't read them.
Reading the data and trusting it are two separate jobs. Getting DG1 off the chip tells you what the chip contains, not that it's genuine. That comes later, from Passive Authentication.
The flow
Each step below depends on the one before it.
1. Reader mode and IsoDep
At the radio level the chip speaks ISO/IEC 14443, with ISO/IEC 7816-4 APDUs on top. On Android that is the IsoDep tag technology. You can get the tag through foreground dispatch or enableReaderMode; for this job reader mode fits better. While your activity is in the foreground, the NFC controller works only as a reader (no card emulation), tags go straight to your callback instead of the dispatch system, and with FLAG_READER_SKIP_NDEF_CHECK the system doesn't try to read NDEF from the chip first.
class EidScanActivity : ComponentActivity(), NfcAdapter.ReaderCallback {
private val nfcAdapter: NfcAdapter? by lazy { NfcAdapter.getDefaultAdapter(this) }
override fun onResume() {
super.onResume()
nfcAdapter?.enableReaderMode(
this,
this,
NfcAdapter.FLAG_READER_NFC_A or
NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
null,
)
}
override fun onPause() {
super.onPause()
nfcAdapter?.disableReaderMode(this)
}
override fun onTagDiscovered(tag: Tag) {
// Not called on the main thread, so blocking I/O is fine here.
val isoDep = IsoDep.get(tag) ?: return // not an ISO 14443-4 chip
try {
isoDep.use { chip ->
chip.connect()
chip.timeout = 5_000 // ms, tune it on real cards and devices
readDocument(chip)
}
} catch (e: TagLostException) {
// The card moved or the field dropped: ask the user to hold still and retry.
} catch (e: IOException) {
// Timeout or transmission error.
}
}
}
readDocument is where the rest of this post happens. Two details in this snippet matter. ID cards and passports can be Type A or Type B, so we enable both. The default IsoDep timeout is too short for some chips during heavy operations such as the PACE key agreement or Active Authentication, so it's common to raise it with setTimeout. The right value comes from testing with real cards and phones.
2. APDUs and selecting the application
A command APDU is CLA INS P1 P2, optionally followed by Lc plus data, and Le (how many bytes you expect back). The response is the data followed by a two-byte status word: 90 00 means success, while values like 69 82 (security status not satisfied) or 6A 82 (file not found) tell you what went wrong.
In the short form Lc and Le are one byte each, so a single response carries at most 256 bytes. Extended length raises that to 65,536, but both the chip and the phone must support it. IsoDep.isExtendedLengthApduSupported() and getMaxTransceiveLength() tell you about the phone side. Short APDUs work everywhere, which makes them the safe default.
The first command selects the eMRTD application by its AID, A0 00 00 02 47 10 01:
// 00 A4 04 0C 07 A0 00 00 02 47 10 01
// SELECT by AID; P2 = 0C means no response data is expected.
private val SELECT_EMRTD_APP = byteArrayOf(
0x00, 0xA4.toByte(), 0x04, 0x0C, 0x07,
0xA0.toByte(), 0x00, 0x00, 0x02, 0x47, 0x10, 0x01,
)
private fun ByteArray.statusWord(): Int {
require(size >= 2) { "Response is shorter than a status word" }
return ((this[size - 2].toInt() and 0xFF) shl 8) or (this[size - 1].toInt() and 0xFF)
}
fun IsoDep.selectEmrtdApplication() {
val sw = transceive(SELECT_EMRTD_APP).statusWord()
check(sw == 0x9000) { "SELECT eMRTD failed: %04X".format(sw) }
}
With BAC, this SELECT is sent in plain, before access control. With PACE the order is different: you first read EF.CardAccess, run PACE, and then select the application under secure messaging.
3. Access control: BAC and PACE
The chip won't hand out data groups until you prove you're allowed to read them. Both mechanisms use data printed on the document, which shows that the document was physically in front of the reader.
- BAC (Basic Access Control): the key seed comes from the MRZ: document number, date of birth and expiry date, each with its check digit. That string is hashed with SHA-1, the 3DES encryption and MAC keys are derived from the hash, and a GET CHALLENGE / MUTUAL AUTHENTICATE exchange then produces the session keys. Its weakness is entropy. The MRZ fields are fairly predictable, so a recorded BAC session can be brute-forced offline.
- PACE (Password Authenticated Connection Establishment): the password is either the same MRZ data or the CAN, the short number printed on the card. PACE runs a Diffie-Hellman key agreement (classic or elliptic curve), so the session keys are strong even though the password isn't. Newer documents support PACE, often alongside BAC.
To choose between them, read EF.CardAccess first. It can be read without access control. If it exists and lists PACE parameters, use PACE; if the chip doesn't support PACE, fall back to BAC. ICAO's guidance is to prefer PACE whenever the chip supports it.
Two practical notes on the MRZ. Validate the check digits before touching the chip, and pad a short document number with < up to nine characters. A wrong MRZ, often from an OCR mistake, makes BAC fail at MUTUAL AUTHENTICATE. Unless you report that case separately, the user sees it as just another failed NFC read.
After BAC or PACE succeeds, every command has to go through secure messaging (SM, from ISO/IEC 7816-4). Command data is encrypted, a MAC is computed over the command, and a send sequence counter (SSC) that both sides increment is part of that MAC. Responses come back the same way and you check their MAC. If you send a plain APDU at this point, the chip rejects it and typically ends the secure session. The same happens if the SSC gets out of sync. In practice, any SM error means starting access control again.
I wrote about the two mechanisms in more detail in BAC vs PACE: Understanding eID Access Control.
4. Reading data groups
With the application selected and SM running, you read a file with READ BINARY (INS B0). There are two ways to address it:
- SELECT the file by its file ID (EF.COM
01 1E, EF.SOD01 1D, DG101 01, DG201 02), then send READ BINARY with the offset in P1-P2. - Use the short EF identifier (SFI): set P1 to
0x80 | SFIand P2 to the offset, and the chip selects and reads in one command. EF.COM is SFI1E, EF.SOD1D, DG101, DG202. This saves a round trip but only covers offsets up to 255. After that first read the file is the current EF, so later chunks use the normal offset form.
Files are BER-TLV encoded. The usual approach is to read the first few bytes, parse the tag and length, and then read the rest in chunks. Under SM each response also carries padding and a MAC, so the payload per read is somewhat below 256 bytes. The offset in P1-P2 is 15 bits, so anything past 32,767 bytes needs the odd-instruction variant (INS B1) with the offset in the command data. DG1 never gets near that size and DG2 rarely does, but your read loop should know about it.
For this part, the open-source jMRTD library is a good reference: how the chunks become TLV structures and how DG1 and DG2 are parsed.
5. Passive Authentication: trusting the data
Passive Authentication (PA) has two parts:
- Verify the signature on EF.SOD with the Document Signer (DS) certificate, which is usually embedded in the SOD. Then verify the DS certificate against the issuing country's CSCA certificate. CSCA certificates don't come from the chip; you obtain them separately, for example through the ICAO PKD or published master lists. Revocation checks (CRLs) belong to this step too.
- Hash each data group you read with the algorithm named in the SOD and compare it with the stored value.
If both pass, the data was issued by that state and hasn't been changed since it was signed. PA doesn't prove that the chip itself is the original: a clone carrying copied files passes PA just as well. Two mechanisms cover that:
- Active Authentication (AA): the reader sends a random challenge (INTERNAL AUTHENTICATE), and the chip signs it with a private key that can't be read out. You verify the signature with the public key in DG15.
- Chip Authentication (CA): specified in ICAO 9303 Part 11 and BSI TR-03110. A key agreement with the chip's static key from DG14 that also replaces the BAC/PACE session with a new secure messaging session. The chip proves it holds the private key implicitly, by being able to continue that session.
Which one you can use depends on what the chip supports (DG15, DG14 or both) and on your security requirements. Both depend on PA: the keys in DG14 and DG15 can only be trusted because their hashes are in the signed SOD. There is more on this in Passive Authentication: Verifying eID Document Integrity.
Splitting the SDK: reading, verification, integration
I split the eID SDK into three parts and kept the boundaries strict:
- Reading: chip communication, access control, secure messaging, APDUs, turning data groups from bytes into a model.
- Verification: signature and hash checks, the certificate chain, trust decisions.
- Integration: wiring the SDK into the app flow, callbacks, progress and error reporting.
The reason is maintenance. The reading layer deals with device quirks, the verification layer with cryptography. If they're mixed, fixing an NFC timeout can end up touching signature code. Kept apart, each layer can be tested on its own (the verification layer runs on plain byte arrays, no phone needed), and the app only sees a small API.
Diagnostics are worth designing in from the start as well. When a read fails in the field, you want to know which stage failed (connection, access control, reading or verification), on which device model, and after how long. Adding this later is much harder.
Device reality
The standards are clear. Reading the same card reliably on many different Android phones is the hard part.
- The antenna is in a different place on every phone. Users don't know where to hold the card. Clear guidance (where to place it, keep it still, roughly how long it takes) directly affects the success rate.
- Connections drop mid-read. After a
TagLostExceptionthe SM session is gone, so you reconnect and run access control again. Data groups you've already read completely don't need to be read again, and the user shouldn't have to restart the whole flow. - Error messages are part of the product. "Read failed" doesn't help anyone. "The MRZ doesn't match the card", "Connection lost, hold the card still" and "Data read but verification failed" are different problems with different fixes.
I go through the common failure cases and how to recover from them in Why NFC Reads Fail on Android (and How to Recover).
Frequently asked questions
- What standard do eID cards and passports follow, and what is stored on the chip?
- They follow ICAO Doc 9303. The chip holds a small file system called the LDS. EF.COM lists the data groups, DG1 holds the MRZ data, DG2 the facial image, and EF.SOD holds a hash of each data group, signed by the issuing state's Document Signer. DG14 and DG15 carry public keys for Chip Authentication and Active Authentication when the chip supports them.
- Why does reading a data group off the chip not prove the identity is genuine?
- Reading the data only tells you what the chip contains. Trust comes from Passive Authentication: you verify the EF.SOD signature with the Document Signer certificate, verify that certificate against the issuing country's CSCA, and compare each data group's hash with the value in the SOD. Even then a cloned chip would pass, which is what Active Authentication and Chip Authentication are for.
- What is the difference between BAC and PACE access control?
- BAC derives its keys from MRZ fields (document number, date of birth, expiry). Those fields are fairly predictable, so a recorded BAC session can be brute-forced offline. PACE uses the MRZ or the CAN as a password but runs a Diffie-Hellman key agreement, so the session keys are strong anyway. Read EF.CardAccess first: if it lists PACE parameters, use PACE, otherwise fall back to BAC.
- Why is reading the same chip reliably across Android devices so hard?
- The NFC antenna sits in a different place on each phone, and the connection can drop mid-read. After a drop the secure messaging session is gone, so you reconnect and run access control again. Clear error messages matter too: a wrong MRZ, a lost connection and a failed verification are different problems with different fixes.