When an app reads an eID chip, pulling the data off the chip is the easy part. The hard question is whether to trust it. Passive Authentication (PA) is the mechanism that answers it. It checks the data you read against a signed manifest the issuing country placed on the chip at personalisation time: the Document Security Object, or SOD. If every data group hashes to exactly the value the SOD commits to, and the SOD's signature chains up to a trusted national authority, then the data is authentic and has not been altered since issuance. That is what Passive Authentication proves.
It is also, and this matters just as much, not proof that the chip in front of you is genuine. I will come back to that, because it is the single most misunderstood point in the whole scheme.
Everything below is public. Passive Authentication is defined in ICAO Doc 9303 Part 11 (Security Mechanisms for MRTDs), building on the data structures in Part 10. I will stay at the level of that standard and of public framework APIs. This is not a description of any employer's system or any vendor's SDK.
Key takeaways
- The SOD is a signed list of hashes, one per data group, plus the certificate that signs it.
- Passive Authentication is three checks in order: verify the SOD signature, validate its certificate up to a trusted CSCA, then recompute each data group's hash and compare.
- PA proves integrity and authenticity of the data. It cannot detect a cloned chip — a byte-for-byte copy passes PA. Chip cloning is caught by Active Authentication or Chip Authentication, not by PA.
Where Passive Authentication sits in the read flow
In my earlier walk-through of reading an eID chip on Android, I split the job into four stages: NFC discovery, access control (BAC or PACE), reading the data groups over APDU, and finally verification. Passive Authentication is that last stage. By the time it runs, you already have a secure channel open and the raw data groups in memory — DG1 (the MRZ), DG2 (the face), and the SOD itself, stored as the elementary file EF.SOD.
Reading and trusting are deliberately separate jobs. Nothing you read is trustworthy until PA has confirmed it. A READ BINARY that succeeds tells you the bytes arrived intact over the air; it says nothing about whether those bytes describe a real person issued by a real authority.
Inside the SOD: a signed manifest of hashes
The SOD is not a bespoke format. It is a CMS SignedData structure (RFC 5652, historically referenced as PKCS#7 / RFC 3369). CMS is the same cryptographic envelope used for signed email and signed documents, which is convenient: the JDK and Bouncy Castle already know how to parse and verify it.
What makes it an eID SOD rather than any other signed blob is its payload. The encapsulated content (encapContentInfo.eContent) is a DER-encoded ASN.1 structure called the LDSSecurityObject, and it contains three things:
- the structure version,
- the hash algorithm used for the data groups (for modern documents, a member of the SHA-2 family),
- a sequence of
DataGroupHashentries, each pairing a data group number with the hash of that data group's contents.
So the SOD does not contain your name or your photo. It contains the hash of the file that contains your name, the hash of the file that contains your photo, and so on — and then it signs that whole list. Alter one byte of DG1 and its hash no longer matches the entry in the SOD. Swap the photo and DG2's hash breaks. The signature over the list makes the list itself tamper-evident.
Carried alongside, in the CMS certificates field, is the Document Signer (DS) certificate — the public key that verifies the SOD's signature. The DS certificate is in turn signed by the country's Country Signing CA (CSCA). That two-tier PKI is the backbone of the whole trust decision.
The three checks, in order
Passive Authentication is three checks, and order matters because each is cheap to describe but meaningless on its own.
1. Verify the SOD signature
First, confirm the SOD really was signed by the DS certificate it carries, and that its signed content was not swapped. In CMS terms: the messageDigest signed attribute must equal a freshly computed hash of the eContent (the LDSSecurityObject), and the signature over the signed attributes must verify under the DS certificate's public key. A CMS library does this for you — you hand it the SOD bytes and the signer certificate, and it returns a yes or no. This step proves the hash list is internally consistent and signed; it does not yet prove who signed it.
2. Validate the certificate chain up to a trusted CSCA
The DS certificate is only worth anything if it chains to a CSCA you already trust. Trusted CSCA certificates come from the ICAO Public Key Directory (PKD) master lists or from bilateral exchange; you ship them as trust anchors and keep them current. Standard java.security path validation handles the chain:
import java.security.cert.CertPathValidator
import java.security.cert.CertificateFactory
import java.security.cert.PKIXParameters
import java.security.cert.TrustAnchor
import java.security.cert.X509Certificate
/**
* Step 2: the Document Signer certificate inside the SOD must chain up
* to a Country Signing CA we already trust (from the ICAO PKD master list).
*/
fun verifyDocumentSignerTrust(
docSigner: X509Certificate,
cscaTrustAnchors: Set<X509Certificate>,
): Boolean = try {
val factory = CertificateFactory.getInstance("X.509")
val path = factory.generateCertPath(listOf(docSigner))
val anchors = cscaTrustAnchors.map { TrustAnchor(it, null) }.toSet()
val params = PKIXParameters(anchors).apply {
// Supply CRLs or an OCSP CertStore here; never leave this off in production.
isRevocationEnabled = false
}
CertPathValidator.getInstance("PKIX").validate(path, params)
true
} catch (e: Exception) {
false
}
Two honest notes. Turning revocation off makes the example short, but a real deployment must check the DS certificate against a current CRL — a revoked signer is exactly the case you care about. And trust-anchor freshness is an operational problem, not a code problem: CSCA certificates roll over, and a stale master list rejects perfectly valid new documents.
3. Recompute the data group hashes
Only now does the data you read enter the picture. For each data group you pulled off the chip, recompute its hash with the algorithm the SOD names, and compare it to the stored value. A single mismatch means the data was altered and the whole result is void. Note the direction: you iterate the groups you actually read and look each one up in the SOD. A data group the SOD commits to but that you never read — a common case, since a typical flow reads only DG1 and DG2 while the SOD may also cover DG14 or DG15 — simply goes unverified, so you must never trust a group you did not read and hash.
import org.jmrtd.lds.SODFile
import java.security.MessageDigest
/**
* Step 3: the data groups we actually read must hash to exactly the
* values the SOD committed to. One mismatch fails the whole document.
*/
fun dataGroupsMatchSod(
sod: SODFile,
readDataGroups: Map<Int, ByteArray>, // DG number -> encoded data group bytes
): Boolean {
val expected: Map<Int, ByteArray> = sod.dataGroupHashes
val digest = MessageDigest.getInstance(sod.digestAlgorithm) // e.g. "SHA-256"
return readDataGroups.all { (dgNumber, bytes) ->
// The SOD must actually commit to every group we read; if it
// doesn't list this one, we have no stored hash to trust it against.
val storedHash = expected[dgNumber] ?: return@all false
val computed = digest.digest(bytes) // digest() already resets the instance
MessageDigest.isEqual(computed, storedHash) // constant-time compare
}
}
Here I lean on jMRTD's SODFile, an open-source reference worth reading even if you parse the SOD yourself. getDataGroupHashes() returns the stored hashes keyed by data group number, getDigestAlgorithm() names the algorithm, and getDocSigningCertificate() hands you the embedded DS certificate for step 2. Note MessageDigest.isEqual rather than Arrays.equals: hash comparison should be constant-time so you are not leaking timing information, a small instance of the defensive mindset I wrote about in SecureCheck.
If all three checks pass, you have proven something specific and strong: the data in front of you was issued by an authority whose CSCA you trust, and it has not been modified since. That is the whole promise of Passive Authentication — and the whole of its limit.
What Passive Authentication does not prove
Here is the point that trips up every first implementation. Passive Authentication validates the data. It says nothing about the chip.
Imagine someone reads a genuine document, captures every data group and the SOD, and writes those exact bytes onto a blank chip. That clone carries the same signed hash list and the same valid DS certificate. It passes all three PA checks perfectly, because PA has no way to ask "are you the original silicon, or a copy of it?" A faithful copy is, to Passive Authentication, indistinguishable from the original.
Detecting a clone requires the chip to prove it holds a secret that cannot be copied off it. ICAO Doc 9303 defines two mechanisms for that, and both are active challenge–response protocols rather than passive checks:
- Active Authentication (AA): the chip stores a private key that never leaves it, with the matching public key published in DG15. The reader sends a random challenge, the chip signs it, and the reader verifies the signature against DG15's key. A clone cannot produce valid signatures because it never had the private key.
- Chip Authentication (CA): part of the Extended Access Control / PACE family, with its key material in DG14. It establishes a fresh secure channel keyed to the chip's own key pair, which both proves the chip is genuine and strengthens the channel.
Both are optional in the standard, and older documents may support neither — so "can this document even prove it is not a clone?" is a question you answer per document, by checking which key material is present. What does not change is the dependency: AA and CA are only meaningful once Passive Authentication has already established that DG15 or DG14 themselves are authentic. You verify the key with PA, then use the key to catch the clone. Skip PA and an attacker simply writes their own DG15.
Make the outcome legible
A boolean return from a verifier is a trap. "Verification failed" tells a support engineer nothing, and it hides whether you are looking at a torn antenna, a revoked signer, or an actual forgery — three problems with three completely different responses. I model the result so the failing stage is explicit:
sealed interface PassiveAuthResult {
data object Trusted : PassiveAuthResult
data class Failed(val stage: Stage, val detail: String) : PassiveAuthResult
enum class Stage { SOD_SIGNATURE, TRUST_CHAIN, DATA_GROUP_HASH }
}
A TRUST_CHAIN failure points at trust-anchor configuration or a revoked certificate — an operations issue. A DATA_GROUP_HASH failure is the alarming one: the data does not match its signed manifest. A SOD_SIGNATURE failure usually means a truncated or corrupt read. Carrying the stage through to your diagnostics turns "it doesn't work" into something you can actually act on, which in a production banking app is the difference between a one-line fix and a week of guessing.
Passive Authentication looks like heavy cryptography from the outside. In practice it is three honest comparisons built on a well-documented standard and a public-key hierarchy you can reason about end to end. The cryptography is the settled part. The engineering is knowing precisely what each check buys you — and being just as clear about the one thing, chip cloning, that it was never designed to catch.