X.509 on iOS: what nobody tells you
Most iOS engineers meet X.509 exactly once, in a certificate-pinning ticket, and never open a certificate's insides again. I didn't get that luxury. The app I built issues personal digital-signature certificates to phones, parses what comes back, and later signs against it, signatures meant to carry real legal weight. These are the parts of that work worth writing down.
Read the certificate; don't derive around it
iOS gives you almost nothing out of the box for reading an X.509 certificate's own fields. SecCertificateCopyValues is macOS-only, so the honest options are hand-roll a DER parser or reach for a real one. We used Apple's own swift-certificates and swift-asn1 libraries, which parse DER and PEM certificates properly and hand back typed fields: subject, issuer, validity window, public key. Reading the validity dates straight off the parsed structure sounds obvious. It wasn't always how the app worked.
// Illustrative — API shape, not the exact call site.
import X509
import SwiftASN1
let certificate = try Certificate(derEncoded: derBytes)
guard certificate.notValidBefore <= now,
now <= certificate.notValidAfter else {
throw CertificateError.outsideValidityWindow
}
Nothing here is derived or cached. The validity window is read straight from the certificate every time, which matters more than it sounds like it should.
A bug that real parsing would have caught
An earlier version of the app didn't do this. It tracked a certificate's expiration as a separate, derived timestamp instead of reading it from the certificate itself, roughly 110 lines of ad hoc logic living inside a legacy signing screen. That kind of value can quietly drift from what the certificate actually encodes, and the derived date only has to disagree with the real one once for a certificate to look valid when it isn't. We found it, deleted the ad hoc logic, and replaced it with real X.509 parsing the same day.
An expiration date isn't a value you calculate. It's a value you read off the certificate the CA actually issued.
Don't trust a returned certificate at face value
Certificate issuance is a round trip: the device generates a keypair, submits the public key along with proof of identity, and gets a signed certificate back. Nothing stops that certificate from coming back bound to the wrong key, whether from a bug, a mixed-up correlation ID, or something worse. Before the app trusts a freshly issued certificate, it independently hashes and compares three things: the public key sitting in the Keychain, a public key it cached locally at generation time, and the SubjectPublicKeyInfo parsed straight out of the certificate. All three have to agree, or the credential is marked invalid instead of assumed correct.
// Illustrative — the real check runs across Keychain, local cache, and Certificate.
let keychainKeyHash = sha256(keychainPublicKeyBytes)
let cachedKeyHash = sha256(cachedPublicKeyBytes)
let certificateKeyHash = sha256(certificate.publicKey.subjectPublicKeyInfoBytes)
guard keychainKeyHash == cachedKeyHash,
cachedKeyHash == certificateKeyHash else {
markCredentialInvalid()
return
}
Sign the hash, not the document
Every signable thing in the app (a certificate-signing request, an acceptance form, a PDF, a CMS envelope) gets reduced to a hash server-side before the device ever sees it. The private key's only job is producing a signature over that hash. It never signs a full document, and the document's full content and the private key never exist off-device at the same time. Nothing about this app ever needed the key to leave the Keychain, because nothing ever asked it to sign more than a hash.
Keys that don't come back
Signing keys are generated directly in the Keychain with a passcode-required, device-only access policy: no iCloud Keychain sync, no restore-to-a-new-device recovery. That's deliberate. A stolen backup has nothing to leak, and a lost phone means re-enrolling from zero rather than restoring a key that was never meant to be portable. Every use of the key, not just its creation, is gated by Face ID or Touch ID through LocalAuthentication, enforced at the app layer and, independently, at the Keychain and Security-framework layer underneath it. Two checks, not one, since an app-level gate alone is only as trustworthy as the screen that calls it.
Proving a person is there, twice over
The certificate-issuance pipeline has to accept two different kinds of evidence that a person is who they claim to be. Attended issuance assumes someone already proved their identity in person and just needs a registration credential. Unattended, remote issuance has no in-person step to lean on, so it asks for a face-scan video and a national ID serial number instead, verified before a certificate is ever started. Both paths land in the same issuance pipeline once identity is established; the client only needs two intake screens, not two certificate systems.
What I'd tell someone starting this today
- Parse certificates with a real library. On Apple platforms that's
swift-certificatesandswift-asn1, not a hand-rolled date calculation that can drift from what's actually encoded. - Never trust a returned certificate on its signature alone. Verify its public key against the key you generated and cached, independently, every time.
- Sign hashes the server computes, not full documents. Keep the private key and the document content from ever both being off-device at once.
- If a key isn't meant to be recoverable, say so in its access policy: device-only, no iCloud sync, passcode required.
- Gate key use with biometrics at two layers, the app and the Keychain, not one.
- Design for more than one identity-proofing path if remote issuance has to feel as trustworthy as showing up in person.