Implementing Certificate Pinning in Mobile Banking Apps

A technical guide to implementing certificate pinning on iOS and Android, covering pinning strategies, rotation plans, and the operational practices that keep mobile banking connections secure.

technical9 min readBy Klivvr Engineering
Share:

When a mobile banking app communicates with its backend, the connection must be secure — not just encrypted, but authenticated. Standard TLS provides encryption and server authentication through the certificate chain of trust, terminating at a root certificate authority (CA). But the CA system has known weaknesses. A compromised or coerced CA can issue a fraudulent certificate for your domain. A user's device might have a corporate proxy CA installed. On a rooted or jailbroken device, an attacker can inject their own trusted CA.

Certificate pinning is the countermeasure. By embedding knowledge of the expected certificate (or its public key) directly in the app, you ensure that the app only trusts connections to servers presenting the specific credentials you control — regardless of what the device's trust store says.

This article covers the practical implementation of certificate pinning on both iOS and Android, the operational considerations that determine whether your pinning strategy helps or hurts, and the mistakes we learned to avoid at Klivvr.

What to Pin: Certificates vs. Public Keys

The first decision is what to pin. You have three options, each with different tradeoffs:

Leaf certificate pinning. You pin the exact certificate presented by your server. This is the most restrictive approach: any certificate change — even a routine renewal with the same key pair — breaks the pin and locks users out of the app.

Public key pinning. You pin the Subject Public Key Info (SPKI) hash of the server's certificate. Since you can renew a certificate while keeping the same key pair, this approach survives routine renewals. It is the most common choice for mobile apps and the one we recommend.

Intermediate CA pinning. You pin the certificate or public key of the intermediate CA that signs your server certificate. This is the most permissive approach: any certificate signed by that CA will be accepted. It provides protection against rogue root CAs but not against a compromise of the intermediate CA itself.

At Klivvr, we pin the SPKI hash of the leaf certificate's public key, plus a backup pin for a key we hold in reserve but have not yet deployed to production. This gives us the security of leaf-level pinning with an escape hatch for emergency rotation.

iOS Implementation with URLSession

On iOS, certificate pinning integrates with URLSession through the URLSessionDelegate protocol. The urlSession(_:didReceive:completionHandler:) method is called during the TLS handshake, giving you the opportunity to inspect the server's certificate chain.

// CertificatePinningDelegate.swift
import Foundation
import CryptoKit
 
class CertificatePinningDelegate: NSObject, URLSessionDelegate {
 
    // SHA-256 hashes of the Subject Public Key Info (SPKI)
    // Primary: current production key
    // Backup: pre-generated key stored offline for emergency rotation
    private let pinnedHashes: Set<String> = [
        "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=", // production
        "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC="  // backup
    ]
 
    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
        }
 
        // Evaluate the standard trust chain first
        var error: CFError?
        guard SecTrustEvaluateWithError(serverTrust, &error) else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }
 
        // Extract the leaf certificate's public key and compute its SPKI hash
        guard let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, 0),
              let serverPublicKey = SecCertificateCopyKey(serverCertificate),
              let serverPublicKeyData = SecKeyCopyExternalRepresentation(serverPublicKey, nil) as Data? else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }
 
        let spkiHash = computeSPKIHash(publicKeyData: serverPublicKeyData)
 
        if pinnedHashes.contains(spkiHash) {
            completionHandler(.useCredential, URLCredential(trust: serverTrust))
        } else {
            // Pin validation failed — do not proceed
            completionHandler(.cancelAuthenticationChallenge, nil)
            logPinningFailure(
                host: challenge.protectionSpace.host,
                receivedHash: spkiHash
            )
        }
    }
 
    private func computeSPKIHash(publicKeyData: Data) -> String {
        // The SPKI header for RSA 2048 keys (ASN.1 prefix)
        let rsa2048SPKIHeader: [UInt8] = [
            0x30, 0x82, 0x01, 0x22, 0x30, 0x0d, 0x06, 0x09,
            0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01,
            0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0f, 0x00
        ]
 
        var spkiData = Data(rsa2048SPKIHeader)
        spkiData.append(publicKeyData)
 
        let hash = SHA256.hash(data: spkiData)
        return Data(hash).base64EncodedString()
    }
 
    private func logPinningFailure(host: String, receivedHash: String) {
        // Report to your security monitoring system
        // Include the received hash so you can diagnose misconfigurations
        SecurityLogger.shared.log(
            event: .pinningFailure,
            metadata: ["host": host, "received_hash": receivedHash]
        )
    }
}

To use this delegate:

let session = URLSession(
    configuration: .default,
    delegate: CertificatePinningDelegate(),
    delegateQueue: nil
)

If you use Alamofire, it provides a built-in ServerTrustManager that simplifies pinning:

let evaluators: [String: ServerTrustEvaluating] = [
    "api.klivvr.com": PublicKeysTrustEvaluator()
]
let manager = ServerTrustManager(evaluators: evaluators)
let session = Session(serverTrustManager: manager)

Android Implementation with OkHttp

On Android, OkHttp provides a CertificatePinner class that makes public key pinning straightforward:

// NetworkModule.kt
import okhttp3.CertificatePinner
import okhttp3.OkHttpClient
 
fun createPinnedClient(): OkHttpClient {
    val certificatePinner = CertificatePinner.Builder()
        .add(
            "api.klivvr.com",
            "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=", // production
            "sha256/CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC="  // backup
        )
        .build()
 
    return OkHttpClient.Builder()
        .certificatePinner(certificatePinner)
        .build()
}

OkHttp's CertificatePinner pins the SPKI hash of certificates in the chain. When the server presents its certificate chain during the TLS handshake, OkHttp computes the SHA-256 hash of each certificate's SPKI and checks it against the pinned values. If none match, the connection is rejected with a SSLPeerUnverifiedException.

For additional defense in depth on Android, you can also use the Network Security Configuration introduced in Android 7.0:

<!-- res/xml/network_security_config.xml -->
<network-security-config>
    <domain-config>
        <domain includeSubdomains="true">api.klivvr.com</domain>
        <pin-set expiration="2025-06-01">
            <pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
            <pin digest="SHA-256">CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=</pin>
        </pin-set>
    </domain-config>
</network-security-config>

The expiration attribute is a safety net: after the specified date, the pins are no longer enforced, preventing a bricked app if you fail to update the pins in time. This is a valuable safety mechanism, but it must be paired with a disciplined rotation schedule so that pins are always updated well before expiration.

Certificate Rotation Strategy

Certificate pinning creates a hard dependency between your app and your server's cryptographic identity. If you rotate your server's key pair without updating the pins in the app, every user running the old app version is locked out. In a banking app, this is catastrophic.

Our rotation strategy at Klivvr follows these principles:

Always pin at least two keys. The production key and a backup key. The backup key is generated and stored securely but not deployed to any server. If the production key is compromised, we can deploy the backup key immediately and the existing app versions will continue to work.

Rotate proactively, not reactively. We rotate server certificates on a fixed schedule (every 12 months), well before expiration. Each rotation follows this sequence:

  1. Generate a new key pair. This becomes the new "backup" key.
  2. Push an app update that adds the new backup key's pin while retaining the existing production and previous backup pins.
  3. Wait for adoption. Monitor the percentage of active users running an app version that includes the new pin. We wait until at least 95% of active users have updated.
  4. Deploy the new key pair to production servers.
  5. In the next app update cycle, remove the oldest pin that is no longer in use.

Monitor pinning failures in production. Our logPinningFailure function reports every failed pin validation to our security monitoring system. A spike in failures could indicate a man-in-the-middle attack — or a misconfigured CDN. Either way, you want to know immediately.

// Centralized pinning failure reporting
class PinningFailureInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        return try {
            chain.proceed(chain.request())
        } catch (e: SSLPeerUnverifiedException) {
            SecurityReporter.report(
                event = "certificate_pinning_failure",
                metadata = mapOf(
                    "host" to chain.request().url.host,
                    "message" to (e.message ?: "unknown")
                )
            )
            throw e
        }
    }
}

Handling Pinning Failures Gracefully

When a pin validation fails, the app cannot communicate with the server. The naive response is to show a generic error. A better response communicates clearly and provides guidance:

Detect the specific failure. Distinguish between a pinning failure and a general network error. The user experience should be different: "Check your internet connection" is wrong if the problem is a compromised network.

Show a security-specific message. Something like "We could not verify a secure connection to our servers. This may happen on certain Wi-Fi networks. Please try again on a different network or contact support." Avoid alarming language like "Your connection is being intercepted" — this may be a corporate proxy, not an attack.

Provide a fallback. If your app has any offline capabilities, allow the user to access cached data (like viewing their last-known balance) even when the pinned connection fails. This prevents a total lockout.

Force update as a last resort. If you have shipped a broken pin configuration (it happens), the fastest recovery path is a force-update mechanism that directs users to download the corrected version from the app store. This requires that your force-update check itself does not go through the pinned connection — a common architectural oversight. We use a separate, unpinned endpoint on a different domain exclusively for version-check calls.

Common Pitfalls

Pinning in debug builds. During development, engineers often use proxy tools like Charles or mitmproxy for debugging. If pinning is active in debug builds, these tools cannot intercept traffic, making debugging painful. We disable pinning in debug builds through a build configuration flag, but we ensure that flag can never be true in a release build:

#if DEBUG
let session = URLSession(configuration: .default)
#else
let session = URLSession(
    configuration: .default,
    delegate: CertificatePinningDelegate(),
    delegateQueue: nil
)
#endif

Forgetting CDN and third-party endpoints. Your API might be at api.klivvr.com, but what about your CDN for images, your analytics endpoint, or your crash reporting service? Each domain the app communicates with needs its own pinning decision. We pin our own API domain and apply standard TLS validation to third-party services.

Not testing the failure path. If you have never seen your pinning code reject a connection, you do not know if it works. We include an integration test that connects to a test endpoint with a deliberately wrong pin and verifies that the connection is refused.

Conclusion

Certificate pinning is one of the most impactful security measures a mobile banking app can implement, but it is also one of the most operationally demanding. The implementation itself is straightforward — a few dozen lines of code on each platform. The hard part is the operational discipline: managing backup keys, planning rotations months in advance, monitoring failures in production, and maintaining the ability to recover when something goes wrong. For a fintech product where every connection carries financial data, that operational investment is not optional. It is the cost of taking your users' security seriously.

Related Articles

business

Reducing Friction in Fintech User Onboarding

Strategies and technical approaches for streamlining fintech user onboarding, from identity verification and KYC to progressive profiling, while balancing regulatory compliance with conversion optimization.

11 min read
business

Layered Security Architecture for Mobile Banking

An in-depth look at the multi-layered security architecture that protects mobile banking apps, from device integrity checks and encrypted storage to runtime protection and network security.

9 min read
business

App Store Optimization for Fintech Products

A comprehensive guide to App Store Optimization (ASO) for fintech mobile apps, covering keyword strategy, creative optimization, ratings management, and the unique challenges of marketing a financial product in competitive app stores.

9 min read