The Modern Guide to Mobile App Certificate Pinning: Secure Your APIs Without Breaking Your App
In the world of mobile security, Man-in-the-Middle (MITM) attacks remain a persistent and dangerous threat. An attacker who can intercept traffic between your mobile app and your backend servers can potentially steal credentials, manipulate data, and compromise user privacy. While Transport Layer Security (TLS) is the foundational defense against this, relying solely on the standard browser-style trust model isn't always enough for high-security applications.
This is where certificate pinning comes in. By "pinning" a specific server certificate or public key within your mobile app, you tell the app to reject any other certificate, even if it appears valid and is signed by a trusted Certificate Authority (CA). It’s a powerful security control that can thwart even sophisticated attackers who have managed to compromise a CA.
However, the history of certificate pinning is littered with cautionary tales of catastrophic app failures. Early, rigid implementations led to entire user bases being locked out when a certificate was unexpectedly rotated. The good news? We've learned from those mistakes. Modern pinning is flexible, resilient, and manageable. This guide will walk you through the current best practices for implementing certificate pinning on Android and iOS, ensuring you enhance security without introducing unacceptable operational risk.
Why Standard TLS Validation Can Be Bypassed
To understand the need for pinning, we must first understand the potential weakness in the standard Public Key Infrastructure (PKI). When your mobile app connects to https://api.yourapp.com, it performs a TLS handshake. During this process, the server presents its X.509 certificate. The mobile OS then validates this certificate by checking a few key things:
- Has the certificate expired?
- Does the name on the certificate match the domain (
api.yourapp.com)? - Is the certificate signed by a CA that the operating system trusts?
The vulnerability lies in that third step. There are hundreds of trusted CAs in the root stores of both Android and iOS. If an attacker manages to compromise any single one of them, they could issue a technically valid certificate for your domain. To your app, this malicious certificate would look perfectly legitimate, allowing the attacker to intercept, decrypt, and modify all traffic. Certificate pinning closes this gap by enforcing a stricter trust policy: the app will only trust certificates that match a pre-defined fingerprint.
The Evolution of Pinning: From Brittle to Resilient
The biggest challenge with pinning isn't the implementation—it's the maintenance. The classic mistake, which caused major outages for companies like Twitter in the past, was hardcoding the hash of a single leaf certificate. When that certificate expired or had to be revoked, every version of the app pinned to it immediately stopped working. This forced users to update the app, which is often a slow and incomplete process.
Modern best practices, championed by organizations like OWASP, are built on flexibility and redundancy:
- Pin Public Keys, Not Certificates: Instead of pinning the entire certificate, you should pin the hash of the Subject Public Key Info (SPKI). A public key can be reused across multiple certificates. This means you can renew your certificate with the same key pair, and your pinned app will continue to work without any changes.
- Always Include Backup Pins: Never rely on a single pin. You should always include at least one backup pin in your app. This backup pin should correspond to a separate key pair and Certificate Signing Request (CSR) that you generate and store securely. If your primary key is ever compromised or you need to perform an emergency rotation, you can issue a new certificate using the backup key pair, and your app will already trust it.
- Use a Manageable Expiration: Pins shouldn't live forever. Setting an expiration date on your pin-set forces you to refresh them periodically through app updates, preventing a situation where a very old, forgotten pin-set causes issues years later.
Implementation Guide: Pinning on Android with Network Security Configuration
Google has made implementing certificate pinning on Android incredibly straightforward and robust with the Network Security Configuration (NSC) file, introduced in Android 7.0 (API 24). This declarative XML approach is the recommended method as it separates security policy from application code, making it easier to manage and audit.
Step 1: Generate Your SPKI Hashes
First, you need to get the SHA-256 hash of the public keys you want to pin. You'll need one for your current server certificate (primary) and one for your backup key.
Let's assume you have your primary certificate (primary.crt) and a certificate generated from your backup key pair (backup.crt). You can generate the SPKI hashes using OpenSSL:
# Generate the SPKI hash for your primary certificate
openssl x509 -in primary.crt -pubkey -noout | \
openssl pkey -pubin -outform DER | \
openssl dgst -sha256 -binary | \
openssl enc -base64
# Expected output is a Base64 string, e.g.:
# p3Exx/n8dY2Dq1c/5k3j4Ie9i7s1j2k3l4m5n6o7p8=
# Generate the SPKI hash for your backup certificate
openssl x509 -in backup.crt -pubkey -noout | \
openssl pkey -pubin -outform DER | \
openssl dgst -sha256 -binary | \
openssl enc -base64
# Expected output is another Base64 string, e.g.:
# q9Rr0sT1uV2wX3y4z5A6b7c8d9e0f1g2h3i4j5k6=
Keep these two Base64 strings. They are your pins.
Step 2: Create the Network Security Configuration File
In your Android project, create a new XML file at res/xml/network_security_config.xml. This is where you'll define your pinning policy.
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<!-- Apply this policy to your API domain and all its subdomains -->
<domain includeSubdomains="true">secure.example.com</domain>
<!-- Pin-set expires in one year. This is a safety mechanism. -->
<pin-set expiration="2025-12-31">
<!-- Primary Pin (SPKI hash of your server's public key) -->
<pin digest="SHA-256">p3Exx/n8dY2Dq1c/5k3j4Ie9i7s1j2k3l4m5n6o7p8=</pin>
<!-- Backup Pin (SPKI hash of your backup public key) -->
<pin digest="SHA-256">q9Rr0sT1uV2wX3y4z5A6b7c8d9e0f1g2h3i4j5k6=</pin>
</pin-set>
</domain-config>
<!-- You can also add a base-config or debug-overrides here -->
<!-- For example, to trust user-installed CAs during development -->
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>
Step 3: Link the NSC File in Your Manifest
Finally, tell your application to use this configuration by adding the android:networkSecurityConfig attribute to the <application> tag in your AndroidManifest.xml.
<application
...
android:networkSecurityConfig="@xml/network_security_config">
...
</application>
That's it. With these changes, your Android app will now enforce certificate pinning for secure.example.com and its subdomains, trusting only certificates that contain one of the two public keys you specified.
Implementation Guide: Pinning on iOS with URLSession
iOS does not offer a declarative configuration file like Android's NSC. Instead, you must implement pinning programmatically by leveraging the URLSessionDelegate. This gives you fine-grained control over the trust evaluation process.
The core of the implementation lies in the urlSession(_:didReceive:completionHandler:) delegate method. When a TLS handshake occurs, this method is called, giving you a chance to inspect the server's certificate chain and decide whether to trust it.
Here is a simplified example in Swift:
```swift
import Foundation
class NetworkService: NSObject, URLSessionDelegate {
// Your pre-defined SPKI hashes for primary and backup keys
private let pinnedHashes: Set<String> = [
"p3Exx/n8dY2Dq1c/5k3j4Ie9i7s1j2k3l4m5n6o7p8=", // Primary
"q9Rr0sT1uV2wX3y4z5A6b7c8d9e0f1g2h3i4j5k6=" // Backup
]
// MARK: - URLSessionDelegate
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
// Ensure the challenge is for server trust evaluation
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
// Not a server trust challenge, cancel.
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
var secresult = SecTrustResultType.invalid
if SecTrustEvaluate(serverTrust, &secresult) != errSecSuccess {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Get the number of certificates in the chain
let certificateCount = SecTrustGetCertificateCount(serverTrust)
// Iterate through the certificate chain
for i in 0..<certificateCount {
guard let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, i),
let serverPublicKey = SecCertificateCopyKey(serverCertificate),
let serverPublicKeyData = SecKeyCopyExternalRepresentation(serverPublicKey, nil) as Data? else {
continue
}
// Hash the public key
let keyHash = sha256(data: serverPublicKeyData)
// Compare with our pinned hashes
if pinnedHashes.contains(keyHash) {
// Success! Public key matches one of our pins.
completionHandler(.useCredential, URLCredential(trust: serverTrust))
return
}
}
// If no matching pin was found after checking the whole chain, fail the challenge.
completionHandler(.cancelAuthenticationChallenge, nil)
}
// Helper function to compute SHA256 and Base64 encode
private func sha