Certificate pinning strengthens SSL/TLS security by validating that the server’s certificate matches a known, trusted certificate. This prevents man-in-the-middle (MITM) attacks even when the device’s trust store is compromised.
For a comprehensive overview of mobile security fundamentals, see our Mobile Security Testing guide.
Why Certificate Pinning Matters
Standard SSL/TLS validation trusts any certificate signed by a Certificate Authority (CA) in the device’s trust store. This creates vulnerabilities:
- Compromised CAs
- Malicious certificates installed by malware
- Corporate proxies performing SSL interception
Certificate pinning mitigates these risks by only trusting specific certificates or public keys.
Android Certificate Pinning
OkHttp CertificatePinner
import okhttp3.CertificatePinner
val certificatePinner = CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // Backup pin
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()
Generating Certificate Pins
# Extract certificate from server
openssl s_client -connect api.example.com:443 -servername api.example.com < /dev/null \
| openssl x509 -outform DER > cert.der
# Generate SHA-256 hash
openssl x509 -in cert.der -inform DER -pubkey -noout \
| openssl pkey -pubin -outform DER \
| openssl dgst -sha256 -binary \
| base64
# Output: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
Testing Certificate Pinning
class CertificatePinningTest {
@Test
fun testValidCertificate() = runTest {
val client = createClientWithPinning()
val response = client.newCall(
Request.Builder()
.url("https://api.example.com/users")
.build()
).execute()
assertTrue(response.isSuccessful)
}
@Test
fun testInvalidCertificate() = runTest {
val client = createClientWithPinning()
// Use invalid hostname
assertThrows<SSLPeerUnverifiedException> {
client.newCall(
Request.Builder()
.url("https://evil.example.com/users")
.build()
).execute()
}
}
@Test
fun testMITMAttempt() = runTest {
// Set up proxy with self-signed certificate
val proxyClient = OkHttpClient.Builder()
.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress("localhost", 8888)))
.certificatePinner(certificatePinner)
.build()
// Should fail due to certificate mismatch
assertThrows<SSLHandshakeException> {
proxyClient.newCall(
Request.Builder()
.url("https://api.example.com/users")
.build()
).execute()
}
}
}
iOS Certificate Pinning
URLSession Delegate
import Foundation
import CommonCrypto
class CertificatePinningDelegate: NSObject, URLSessionDelegate {
private let pinnedCertificates: Set<Data>
init(pinnedCertificates: Set<Data>) {
self.pinnedCertificates = pinnedCertificates
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let certificates = (0..<SecTrustGetCertificateCount(serverTrust)).compactMap { index in
SecTrustGetCertificateAtIndex(serverTrust, index)
}
for certificate in certificates {
let certificateData = SecCertificateCopyData(certificate) as Data
if pinnedCertificates.contains(certificateData) {
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
return
}
}
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
// Usage
let pinnedCerts = Set([loadCertificate(named: "api_example_com")])
let delegate = CertificatePinningDelegate(pinnedCertificates: pinnedCerts)
let session = URLSession(
configuration: .default,
delegate: delegate,
delegateQueue: nil
)
Testing iOS Pinning
class CertificatePinningTests: XCTestCase {
func testValidCertificate() async throws {
let pinnedCerts = Set([loadCertificate(named: "valid_cert")])
let delegate = CertificatePinningDelegate(pinnedCertificates: pinnedCerts)
let session = URLSession(
configuration: .default,
delegate: delegate,
delegateQueue: nil
)
let url = URL(string: "https://api.example.com/users")!
let (data, response) = try await session.data(from: url)
XCTAssertNotNil(data)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
}
func testInvalidCertificate() async {
let pinnedCerts = Set([loadCertificate(named: "valid_cert")])
let delegate = CertificatePinningDelegate(pinnedCertificates: pinnedCerts)
let session = URLSession(
configuration: .default,
delegate: delegate,
delegateQueue: nil
)
let url = URL(string: "https://evil.example.com/users")!
do {
_ = try await session.data(from: url)
XCTFail("Should have thrown error")
} catch {
// Expected failure
XCTAssertTrue(error is URLError)
}
}
}
Public Key Pinning
More flexible than certificate pinning, allows certificate rotation without app update.
Android Public Key Pinning
class PublicKeyPinning : CertificatePinner {
private val pinnedPublicKeys = setOf(
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
)
override fun check(hostname: String, peerCertificates: List<Certificate>) {
val publicKeyHashes = peerCertificates.map { cert ->
val publicKey = cert.publicKey.encoded
sha256(publicKey).base64()
}
if (publicKeyHashes.none { it in pinnedPublicKeys }) {
throw SSLPeerUnverifiedException("Certificate pinning failure!")
}
}
private fun sha256(data: ByteArray): ByteArray {
return MessageDigest.getInstance("SHA-256").digest(data)
}
}
Pin Rotation Strategy
Backup Pins
Always include backup pins to allow graceful rotation:
val certificatePinner = CertificatePinner.Builder()
.add("api.example.com",
"sha256/PRIMARY_PIN_HASH")
.add("api.example.com",
"sha256/BACKUP_PIN_HASH_1")
.add("api.example.com",
"sha256/BACKUP_PIN_HASH_2")
.build()
Remote Pin Configuration
Fetch pins from a remote configuration service:
class DynamicPinningManager(
private val configService: ConfigService,
private val secureStorage: SecureStorage
) {
suspend fun updatePins() {
val newPins = configService.getCertificatePins()
// Validate new pins before applying
if (validatePins(newPins)) {
secureStorage.savePins(newPins)
}
}
fun createClient(): OkHttpClient {
val pins = secureStorage.getPins()
val pinnerBuilder = CertificatePinner.Builder()
pins.forEach { (hostname, pinHashes) ->
pinHashes.forEach { hash ->
pinnerBuilder.add(hostname, "sha256/$hash")
}
}
return OkHttpClient.Builder()
.certificatePinner(pinnerBuilder.build())
.build()
}
}
@Test
fun testPinRotation() = runTest {
val manager = DynamicPinningManager(mockConfigService, storage)
// Initial pins
val initialPins = mapOf("api.example.com" to setOf("OLD_PIN"))
storage.savePins(initialPins)
// Rotate pins
mockConfigService.respondWith(mapOf(
"api.example.com" to setOf("OLD_PIN", "NEW_PIN")
))
manager.updatePins()
// Should work with both old and new pin
val client = manager.createClient()
assertTrue(client.certificatePinner.pins.size == 2)
}
Testing MITM Detection
MITM detection is critical for mobile security. Learn more advanced security testing techniques in our Penetration Testing Basics guide.
Using Charles Proxy or mitmproxy
@Test
fun testMITMProxyDetection() = runTest {
// Configure app to use proxy
System.setProperty("http.proxyHost", "localhost")
System.setProperty("http.proxyPort", "8888")
val client = createClientWithPinning()
// Should fail when proxy uses self-signed cert
assertThrows<SSLPeerUnverifiedException> {
client.newCall(
Request.Builder()
.url("https://api.example.com/users")
.build()
).execute()
}
}
Bypass Detection
Detecting Certificate Pinning Bypass Tools
class SecurityChecker {
fun detectRootAndBypassTools(): SecurityStatus {
val issues = mutableListOf<String>()
// Check for root
if (isDeviceRooted()) {
issues.add("Device is rooted")
}
// Check for Frida
if (isFridaRunning()) {
issues.add("Frida detected")
}
// Check for Xposed
if (isXposedInstalled()) {
issues.add("Xposed framework detected")
}
// Check for emulator
if (isEmulator()) {
issues.add("Running on emulator")
}
return if (issues.isEmpty()) {
SecurityStatus.Secure
} else {
SecurityStatus.Compromised(issues)
}
}
private fun isDeviceRooted(): Boolean {
val paths = arrayOf(
"/system/app/Superuser.apk",
"/sbin/su",
"/system/bin/su",
"/system/xbin/su"
)
return paths.any { File(it).exists() }
}
private fun isFridaRunning(): Boolean {
try {
val process = Runtime.getRuntime().exec("ps")
val reader = BufferedReader(InputStreamReader(process.inputStream))
return reader.lines().anyMatch { it.contains("frida") }
} catch (e: Exception) {
return false
}
}
}
@Test
fun testSecurityChecks() {
val checker = SecurityChecker()
val status = checker.detectRootAndBypassTools()
when (status) {
SecurityStatus.Secure -> {
// Proceed normally
}
is SecurityStatus.Compromised -> {
// Log security issues
log.warn("Security issues detected: ${status.issues}")
}
}
}
Best Practices
- Always use backup pins - Include 2-3 pins for rotation
- Pin to public keys, not certificates - More flexible for cert rotation
- Monitor pin expiration - Set up alerts before certs expire
- Test pinning in CI/CD - Detect misconfiguration early
- Implement remote pin updates - For emergency rotation
- Log pinning failures - Monitor for MITM attempts
- Combine with other security measures - Root detection, SSL validation
Certificate pinning works best when combined with OAuth 2.0 and JWT testing for comprehensive authentication security. For QA engineers looking to expand security knowledge, check Security Testing for QA.
Conclusion
Certificate pinning testing ensures:
- MITM attack prevention
- Proper SSL/TLS validation
- Graceful pin rotation
- Detection of bypass attempts
Properly implemented and tested certificate pinning significantly enhances mobile app security, protecting sensitive data from interception attacks.