Implementing Certificate Pinning in mobile applications
Applications are intercepted via Charles Proxy or Frida — traffic is read, responses modified, APIs studied. This happens because TLS connection validates the certificate against system CAs, and the user (or pentester) simply added their CA to trusted. Certificate pinning closes this vector: the client accepts only a specific certificate or key, regardless of system certificate storage.
Certificate pinning vs public key pinning
Certificate pinning — client stores the full certificate (or its SHA-256 hash) and compares with what the server sent. Simple to implement, but the certificate changes with each renewal (typically every 1–2 years). If you forgot to update the pin before rotation — the app stops working for all users at once.
Public key pinning (HPKP-style) — pins the hash of SubjectPublicKeyInfo. When the certificate rotates, the private key stays the same, pin remains valid. This is the right choice for production. Additionally always keep a backup pin — hash of a backup key (may be from your CA), so if the main key is compromised you're not left without the ability to update clients.
iOS: NSURLSession + TrustKit
Native way via URLSessionDelegate:
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Extract public key from server certificate
guard let serverCert = SecTrustGetCertificateAtIndex(serverTrust, 0),
let serverKey = SecCertificateCopyKey(serverCert) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let serverKeyData = SecKeyCopyExternalRepresentation(serverKey, nil)! as Data
let serverKeyHash = sha256(data: serverKeyData)
let pinnedHashes = ["base64encodedSHA256ofSubjectPublicKeyInfo=="]
if pinnedHashes.contains(serverKeyHash) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
For projects with multiple hosts, TrustKit is convenient — configured via Info.plist or dictionary, supports backup pins, reporting violations to endpoint.
Android: OkHttp CertificatePinner
Most Android projects use OkHttp for networking:
val certificatePinner = CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAA...primaryKeyHash==")
.add("api.example.com", "sha256/BBBBBBBBB...backupKeyHash==")
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()
OkHttp itself calculates SHA-256 SubjectPublicKeyInfo and compares. On mismatch — SSLPeerUnverifiedException. Hashes can be obtained via openssl:
openssl s_client -connect api.example.com:443 -servername api.example.com 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| openssl enc -base64
For Flutter — http_certificate_pinning package or native implementation via platform channels, since dart:io HttpClient doesn't support custom public key validation without wrappers.
Problems with pin updates and Debug builds
The main operational pain — key rotation. 30+ days before certificate replacement, release an update with new backup pin in the list, give users time to update, then rotate the key. This requires coordination between infrastructure and mobile teams.
In debug/QA builds, pinning is usually disabled so Charles/Proxyman works for testers. Done via BuildConfig.DEBUG flag or separate flavor without pinning config. Make sure release build in CI is assembled with pinning — otherwise you lose protection without noticing.
Bypassing Frida and root
Certificate pinning doesn't protect against attacks on rooted devices — Frida can hook SSLContext or hash comparison methods. That's a separate task (root detection + runtime integrity check). Pinning protects against proxy on non-rooted devices, covering most user interception scenarios.
Timelines
Setting up pinning on one host with correct backup-pin scheme — 2–3 days including testing, CI configuration for release build and documenting key rotation procedure.







