Mobile certificate pinning — source audit
TL;DR: From source, certificate pinning is either (1) configured declaratively (Android Network Security Config, iOS info.plist pin set in modern SDKs) or (2) implemented in code via
X509TrustManager/OkHttpClient.certificatePinner(Android) orURLSessionDelegate.didReceive challenge:(iOS). Common audit findings: pin missing for sensitive hosts, pin code that returns true on any error path, pin disabled in debug builds and the conditional that ships to prod, and SDKs that bypass the app’s pinning entirely. Companion to ssl-pinning-bypass (attacker angle).
Android — Network Security Config
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- res/xml/network_security_config.xml -->
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2026-12-31">
<pin digest="SHA-256">...primary key SPKI hash...</pin>
<pin digest="SHA-256">...backup key SPKI hash...</pin>
</pin-set>
<trust-anchors>
<certificates src="system"/>
</trust-anchors>
</domain-config>
<debug-overrides>
<trust-anchors>
<certificates src="user"/> <!-- accept user CAs in debug -->
</trust-anchors>
</debug-overrides>
</network-security-config>
Audit:
- Every sensitive host has a
domain-configwithpin-set. - At least two pins (primary + backup) so rotation doesn’t brick clients.
expirationis far enough out but not so far it lives past the cert’s own lifetime.debug-overridesonly active underandroid:debuggable="true".
1
2
grep -rn 'networkSecurityConfig' AndroidManifest.xml
find . -name 'network_security_config.xml'
Android — OkHttp CertificatePinner
1
2
3
4
5
6
7
8
val client = OkHttpClient.Builder()
.certificatePinner(
CertificatePinner.Builder()
.add("api.example.com", "sha256/...primary...")
.add("api.example.com", "sha256/...backup...")
.build()
)
.build()
Audit:
- Pins added for every OkHttpClient instance that talks to the sensitive backend.
- No
.dispatcher(...)oraddInterceptor(...)that swap in a different client at runtime. - No third-party SDK creating its own OkHttpClient without pinning (analytics SDKs, payment SDKs).
Android — custom X509TrustManager traps
1
2
3
4
5
val trustAll = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {} // BAD
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
}
If the checkServerTrusted body is empty or always returns successfully, there is no validation. Search:
1
2
grep -rn 'X509TrustManager\|HostnameVerifier\|ALLOW_ALL_HOSTNAME_VERIFIER' src/
grep -rn 'checkServerTrusted' src/ -A5
iOS — URLSession server trust challenge
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil); return
}
// pin the leaf or SPKI
if pinMatches(trust) {
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
Trap forms:
completionHandler(.useCredential, URLCredential(trust: trust))withoutpinMatches— accept any cert.pinMatchesthat only compares the issuer CN — a rogue CA issues anything.- Pin check that returns true on Keychain lookup error.
1
grep -rn 'didReceive challenge\|serverTrust\|URLCredential(trust:' .
iOS — App Transport Security exceptions
ATS pinning (NSPinnedDomains in iOS 14+):
1
2
3
4
5
6
7
8
9
10
11
<key>NSAppTransportSecurity</key>
<dict>
<key>NSPinnedDomains</key>
<dict>
<key>api.example.com</key>
<dict>
<key>NSPinnedLeafIdentities</key>
<array><dict><key>SPKI-SHA256-BASE64</key><string>...</string></dict></array>
</dict>
</dict>
</dict>
This is declarative pinning enforced by NSURLSession. Audit Info.plist for presence + correctness.
Cross-cutting bugs
Pin only on first request
Some libraries check the pin only on the first request and cache trust; subsequent requests go through a cached SecTrustRef without re-verification.
Debug-bypass shipped to production
1
2
3
if (BuildConfig.DEBUG || someFlag) {
// skip pinning
}
A “flag” that’s not strictly BuildConfig.DEBUG may ship enabled (toggled by a remote config feature flag, or by accident).
Third-party SDKs
Each SDK has its own HTTP stack. Audit:
- Analytics SDKs (Firebase, Mixpanel, Segment).
- Auth SDKs (Auth0, Okta).
- Payment SDKs (Stripe, Braintree). Each should be pinning the SDK’s backend host, or at least not breaking your app’s pin checks.
WebView and WKWebViewConfiguration
WKWebView uses NSURLSession-backed networking but ignores your URLSessionDelegate. iOS 13+ supports WKWebsiteDataStore and ATS pinning for content; older builds may bypass. See ios-wkwebview-audit.
Pin source choice
| Type | Lifetime | Rotation cost |
|---|---|---|
| Leaf cert pin | Until cert expires (1y typical) | Each renewal |
| Intermediate cert pin | Years | Few renewals |
| SPKI (Subject Public Key Info) pin | Until key rotation | Rare |
| Root CA pin | Years/decades | Almost never |
Industry preference: SPKI pin with two pins (current key + backup key) and a process to swap before expiry.
Source-audit checklist
- Sensitive hosts pinned via declarative config or in code.
- At least one backup pin per host.
- Pin expiry tracked in CI.
- No empty
checkServerTrusted/ always-true trust manager. - iOS
URLSessionDelegateactually rejects on mismatch (not cancel-then-retry). - Debug overrides limited to
BuildConfig.DEBUG/#if DEBUG. - Third-party SDK HTTP stacks reviewed.