SSL pinning: when you need it, how to do it right, how it gets bypassed

Pinning shrinks the set of CAs you trust from hundreds of roots to a few keys, and in exchange ties your certificate calendar to your app releases. Correct setup on iOS and Android, a rotation runbook, telemetry, testing, and how pentesters get past it.

We see two kinds of pinning incident. In the first, a security audit files a "no certificate pinning" finding, the team adds it in two days, and everyone relaxes. In the second, fourteen months later, the infrastructure team renews a certificate, every version of the app loses its connection in the same minute, and nobody is relaxed. The cause of the second incident is almost always the first.

Pinning itself is five lines of configuration. The hard part is wiring those five lines to the certificate calendar, the CDN contract, the release adoption curve and the on-call procedure. This article covers both.

Where trust comes from on a phone, and what pinning adds

During the TLS handshake the server presents a certificate chain. The client checks signatures, dates and the host name, then checks whether the chain ends at a root in the device's trust store. That store holds well over a hundred root CAs, and each of them can, technically, issue a valid certificate for your domain.

The model has two weak spots. A mistake or compromise at any one of those CAs affects your domain too. And a root can be added to the device later: a corporate MDM profile, a proxy certificate the user installed, a malicious configuration profile.

The platforms already close most of the second gap. On iOS, App Transport Security requires TLS 1.2 or later, forward secrecy and adequate key sizes for connections made through the URL Loading System. Apps targeting Android 7.0 (API 24) and later do not trust user-added CAs by default, and since Android 9 cleartext traffic is off by default as well. On iOS a root the user installs by hand is not trusted for TLS until they separately enable full trust in Settings.

Pinning adds one thing on top: the accepted chain must contain a public key you knew in advance. It shrinks a trust set of a hundred-plus roots down to two or three keys. That protects you against a CA that mis-issues and against a root added to the device.

What it does not add deserves the same clarity. Pinning does not protect against an attacker who owns the device; on a rooted or jailbroken phone the check is switched off at runtime. It does nothing if your server is compromised. It does not protect your API from automation. It delays inspection of your traffic and does not prevent it.

Which apps, which endpoints

OWASP MASVS-NETWORK-2 expects identity pinning for all remote endpoints under the developer's control. OWASP's own Pinning Cheat Sheet says there is almost no situation where you should consider pinning, because the outage risk usually outweighs the security gain. The Android documentation goes further and states plainly that pinning is not recommended for Android apps. Apple's article calls pinning optional and says to use it only when necessary.

That looks contradictory and is in fact consistent: pinning is a control for teams that can pay for it. Our decision table:

Situation Decision
Payments, banking, health data, authentication endpoints; a regulator or audit requires MASVS-NETWORK-2 Pin, together with the runbook below
An ordinary API carrying session tokens, and you control the certificate and key Pin if rotation has a named owner; otherwise platform defaults are enough
CDN-managed certificates whose keys you never see Do not pin the leaf; upload your own certificate or do not pin
Third-party domains (analytics, maps, a payment provider's SDK) Do not pin; the keys are not yours and neither is the calendar
Content, images, marketing pages Do not pin

The operational price has three parts: sequencing certificate and CA changes with app releases, accounting for old versions that stay in the field for years, and having it written down who does what when a lockout happens at midnight. If nobody will own those three, do not add pinning. A pin set added "for now" becomes a timer nobody remembers until certificate renewal day.

What to pin: leaf, intermediate, root

Format first. Pin the public key (SubjectPublicKeyInfo, SPKI) and never the whole certificate. A certificate gets renewed, its dates and serial number change, and the key can stay the same. Pinning the certificate means an app update on every renewal, and renewals are getting more frequent: under the schedule adopted by the CA/Browser Forum, maximum certificate lifetime shrinks in steps starting in March 2026 and reaches 47 days in March 2029. Let's Encrypt moves its default profile to 64 days in February 2027 and 45 days in February 2028.

Then position in the chain:

Pinned Gain Price
Leaf key Narrowest trust: only your key The key must be preserved on every renewal; ACME clients often generate a new key by default
Intermediate The leaf renews freely The CA can change intermediates without asking; Let's Encrypt says outright that this will happen
Root Least fragile You trust every certificate that CA issues

Our recommendation splits into two cases. If you generate the key and run the renewal yourself (your own load balancer, a custom certificate uploaded to a CDN), pin the leaf SPKI, add the hash of a backup key that has never been on any server and is stored offline, and reuse the key on renewal (--reuse-key in certbot). If a provider manages the certificate, pin at CA level: the root or a long-lived intermediate of the CA you use, plus the root of a second, independent CA you have an agreement with. Apple's article also recommends CA pinning as the long-term strategy, and the Android documentation asks that at least one backup be a key fully under your control. Combining the two is possible and sensible.

Whichever you choose, the pin set cannot have one element.

Pinning without a backup pin is an outage whose date you did not choose.

Generating pins

From the leaf key of a live host:

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform der \
  | openssl dgst -sha256 -binary \
  | openssl enc -base64

That command only reads the first certificate in the chain. For an intermediate or a root, generate from a certificate file (the PEM your CA publishes, or a block cut out of -showcerts output):

openssl x509 -in intermediate.pem -pubkey -noout \
  | openssl pkey -pubin -outform der \
  | openssl dgst -sha256 -binary \
  | openssl enc -base64

A backup key needs no certificate; the hash comes from the public key. Generate the key, put the private half in an HSM or an offline vault, and embed the hash in the app:

openssl ecparam -name prime256v1 -genkey -noout -out backup.key
openssl pkey -in backup.key -pubout -outform der \
  | openssl dgst -sha256 -binary | openssl enc -base64

The result is a 44-character Base64 string and it is identical across iOS, Android and OkHttp. The hashes in the examples below are placeholders; generate yours from your own chain.

iOS: NSPinnedDomains first, a delegate if you must

Declarative pinning in Info.plist

Since iOS 14, pinning can be declared in Info.plist as part of the ATS configuration:

<key>NSAppTransportSecurity</key>
<dict>
  <key>NSPinnedDomains</key>
  <dict>
    <key>api.example.com</key>
    <dict>
      <key>NSIncludesSubdomains</key>
      <false/>
      <key>NSPinnedCAIdentities</key>
      <array>
        <dict>
          <key>SPKI-SHA256-BASE64</key>
          <string>r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E=</string>
        </dict>
        <dict>
          <key>SPKI-SHA256-BASE64</key>
          <string>i7WTqTvh0OioIruIfFR4kMPnBqrS2rdiVPl/s2uC/CY=</string>
        </dict>
      </array>
    </dict>
  </dict>
</dict>

The details worth knowing:

  • NSPinnedCAIdentities matches the keys of intermediate or root certificates in the chain. NSPinnedLeafIdentities matches only the server certificate's key. With several entries in one array, a match on any one is enough.
  • If you declare both, ATS requires a match in each category. It means "leaf and CA", never "leaf or CA". Teams that add a leaf pin next to the CA array as a "backup" lock themselves out this way.
  • NSIncludesSubdomains covers direct subdomains: enabled for example.com it covers api.example.com and, going by Apple's example, not v2.api.example.com.
  • Pinning does not change how the system evaluates that certificate as a trust anchor. Standard evaluation still runs and the pin is added on top. Pinning your private CA does not make it trusted.
  • ATS only covers the URL Loading System (URLSession). The Network framework and libraries that bring their own TLS stack are unaffected. For WKWebView content, test the behaviour on the OS versions you ship to; Apple's pinning documentation does not spell it out.

The approach has two gaps: there is no expiry date and no hook that tells you about a failure. The connection simply dies with an ordinary TLS error. If your pin set is static and you can read telemetry from a traffic drop on the server side, this is still our first choice, because it contains no code that can be written wrong.

When you need your own logic: URLSessionDelegate

If you want to update pins remotely, report failures or switch pinning off in debug builds, you write the check yourself. This is the mistake we see most often in the field:

// Before: no system evaluation, just a byte comparison of the leaf certificate
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
                completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
    let trust = challenge.protectionSpace.serverTrust!
    let chain = SecTrustCopyCertificateChain(trust) as! [SecCertificate]
    let serverData = SecCertificateCopyData(chain[0]) as Data
    if serverData == bundledCertificateData {
        completionHandler(.useCredential, URLCredential(trust: trust))
    } else {
        completionHandler(.cancelAuthenticationChallenge, nil)
    }
}

Three problems. SecTrustEvaluateWithError is never called, so expiry and host name checks are left to the pin match. The whole certificate is compared, so the app locks out at the first renewal. And the delegate applies the same check to every host, so every other domain the app talks to is rejected too.

The correct version has two steps: the system's standard evaluation first, then the SPKI hashes of the keys in the chain. The second step hides a trap. SecKeyCopyExternalRepresentation does not return the SubjectPublicKeyInfo structure: it returns PKCS #1 for RSA and ANSI X9.63 (04 || X || Y) for elliptic curve keys. openssl and Android hash the complete SPKI. To arrive at the same value you have to prepend the fixed ASN.1 header for the key type and size:

import CryptoKit
import Foundation
import Security

enum SPKIHash {
    // AlgorithmIdentifier + BIT STRING headers: the part SecKey leaves out
    private static let rsa2048: [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,
    ]
    private static let rsa4096: [UInt8] = [
        0x30, 0x82, 0x02, 0x22, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86,
        0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x02, 0x0f, 0x00,
    ]
    private static let ecP256: [UInt8] = [
        0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02,
        0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03,
        0x42, 0x00,
    ]
    private static let ecP384: [UInt8] = [
        0x30, 0x76, 0x30, 0x10, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02,
        0x01, 0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x22, 0x03, 0x62, 0x00,
    ]

    static func base64(of key: SecKey) -> String? {
        guard let attributes = SecKeyCopyAttributes(key) as? [CFString: Any],
              let type = attributes[kSecAttrKeyType] as? String,
              let bits = attributes[kSecAttrKeySizeInBits] as? Int,
              let raw = SecKeyCopyExternalRepresentation(key, nil) as Data?
        else { return nil }

        let rsa = String(kSecAttrKeyTypeRSA)
        let ec = String(kSecAttrKeyTypeECSECPrimeRandom)
        let header: [UInt8]
        switch (type, bits) {
        case (rsa, 2048): header = rsa2048
        case (rsa, 4096): header = rsa4096
        case (ec, 256): header = ecP256
        case (ec, 384): header = ecP384
        default: return nil   // a key type we do not know produces no match
        }

        var hasher = SHA256()
        hasher.update(data: Data(header))
        hasher.update(data: raw)
        return Data(hasher.finalize()).base64EncodedString()
    }
}

The RSA headers assume the usual public exponent, 65537. If your chain contains 3072-bit RSA or P-521, add the header the same way: it is the bytes at the start of openssl pkey -pubout -outform der output, before the raw key. Once this code exists, the first thing to do is compare its output with the openssl command above for the same server.

The delegate itself:

struct PinFailure: Sendable {
    let host: String
    let seenHashes: [String]
    let systemTrustPassed: Bool
}

final class PinningDelegate: NSObject, URLSessionDelegate {
    private let pins: [String: Set<String>]          // host -> SPKI hashes
    private let onFailure: @Sendable (PinFailure) -> Void

    init(pins: [String: Set<String>], onFailure: @escaping @Sendable (PinFailure) -> Void) {
        self.pins = pins
        self.onFailure = onFailure
    }

    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge
    ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        let space = challenge.protectionSpace
        guard space.authenticationMethod == NSURLAuthenticationMethodServerTrust,
              let trust = space.serverTrust,
              let expected = pins[space.host]
        else { return (.performDefaultHandling, nil) }   // unpinned hosts stay with the system

        // 1. Standard evaluation: chain, dates, host name, ATS rules
        var error: CFError?
        guard SecTrustEvaluateWithError(trust, &error) else {
            onFailure(PinFailure(host: space.host, seenHashes: [], systemTrustPassed: false))
            return (.cancelAuthenticationChallenge, nil)
        }

        // 2. Is any key in the evaluated chain part of the pin set
        let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate] ?? []
        let seen = chain
            .compactMap { SecCertificateCopyKey($0) }
            .compactMap { SPKIHash.base64(of: $0) }

        guard !expected.isDisjoint(with: seen) else {
            onFailure(PinFailure(host: space.host, seenHashes: seen, systemTrustPassed: true))
            return (.cancelAuthenticationChallenge, nil)
        }
        return (.useCredential, URLCredential(trust: trust))
    }
}

Apple asks that SecTrustEvaluateWithError not be called on the main run loop because it may need network access; pass nil as delegateQueue and the session runs the delegate on its own background queue. A rejected request comes back to the caller as NSURLErrorCancelled (-999), so map that in your networking layer if you want the user to see something more useful than "cancelled". To use a proxy in debug builds, pass an empty pin dictionary and leave the code alone.

This delegate covers only the URLSession you hand it to. Third-party SDKs that create their own session and WKWebView are outside; for the web view you need the same check in the WKNavigationDelegate authentication callback.

As a ready-made library, TrustKit is still a reasonable option: at the time of writing the repository is not archived, the latest release is 3.0.7 (June 2025) and it has received commits during 2026. Failure reporting comes built in. We recommend its explicit validation API over the auto-pinning mode that swizzles delegates; behaviour that is invisible in the networking layer is behaviour nobody remembers a year later.

Android: Network Security Config and OkHttp

Network Security Config

<!-- res/xml/network_security_config.xml -->
<network-security-config>
  <base-config cleartextTrafficPermitted="false" />

  <domain-config>
    <domain includeSubdomains="false">api.example.com</domain>
    <domain includeSubdomains="false">auth.example.com</domain>
    <pin-set expiration="2027-10-01">
      <pin digest="SHA-256">r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E=</pin>
      <!-- backup: root of a second CA -->
      <pin digest="SHA-256">i7WTqTvh0OioIruIfFR4kMPnBqrS2rdiVPl/s2uC/CY=</pin>
      <!-- backup: our own key, stored offline -->
      <pin digest="SHA-256">fwza0LRMXouZHRC8Ei+4PyuldPDcf3UKgO/04cDM1oE=</pin>
    </pin-set>
  </domain-config>

  <debug-overrides>
    <trust-anchors>
      <certificates src="user" />
    </trust-anchors>
  </debug-overrides>
</network-security-config>

The file is attached in the manifest with android:networkSecurityConfig="@xml/network_security_config". How it behaves:

  • A chain is valid if it contains at least one of the pinned keys. There is no leaf, intermediate or root distinction; whichever hash you wrote is the level you pinned at.
  • After expiration (yyyy-MM-dd) the pins are no longer enforced and the connection proceeds on standard validation alone. This is what stops old, never-updated versions from being locked out forever. The same documentation states the price: an expiry date may let attackers bypass your pins. We set a date about a year out and move it forward in every release. A team that forgets to move it loses pinning silently, and keeps its users.
  • debug-overrides applies only to builds with android:debuggable="true". Trust anchors declared there default to overridePins="true": when the chain ends at a debug anchor, pins are not checked. In a release build the whole section is ignored, so you do not need separate files for debug and release.
  • domain-config elements can be nested and inherit unset values from the enclosing configuration. includeSubdomains="true" covers every level below (unlike the single level on iOS).
  • The certificateTransparency element that arrived with Android 16 complements pinning: it catches mis-issued certificates without any pin operations, and it works on domains you do not pin.

OkHttp CertificatePinner

val pinner = CertificatePinner.Builder()
    .add("api.example.com",
        "sha256/r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E=",
        "sha256/i7WTqTvh0OioIruIfFR4kMPnBqrS2rdiVPl/s2uC/CY=",
        "sha256/fwza0LRMXouZHRC8Ei+4PyuldPDcf3UKgO/04cDM1oE=")
    .build()

// Application interceptor that reports a pin failure and rethrows it unchanged
class PinFailureReporter(
    private val report: (host: String, detail: String) -> Unit,
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response =
        try {
            chain.proceed(chain.request())
        } catch (e: SSLPeerUnverifiedException) {
            report(chain.request().url.host, e.message.orEmpty())
            throw e
        }
}

val client = OkHttpClient.Builder()
    .certificatePinner(pinner)
    .addInterceptor(PinFailureReporter(telemetry::pinFailure))
    .build()

Pattern syntax: api.example.com is an exact match, *.example.com matches one subdomain level, **.example.com matches subdomains at any depth. The check runs after the TLS handshake and standard verification have succeeded, against the cleaned chain. On failure it throws SSLPeerUnverifiedException, and the message lists the hashes of the chain the server presented alongside the pins that were expected, which is exactly what telemetry needs.

Choosing between NSC and OkHttp: NSC is declarative and comes with expiry and debug behaviour, but it gives you no hook on failure and is fixed at build time. CertificatePinner covers only the OkHttpClient you build, but lets you change pins at runtime and see the failure. We use NSC for a static pin set, and OkHttp when pins are updated remotely or telemetry is required. If you use both for the same domain, remember to update both lists during a rotation.

What is covered and what is not

NSC covers stacks that use the platform TrustManager: HttpsURLConnection and, according to OWASP MASTG, WebView. OkHttp relies on the platform trust manager on Android and is therefore subject to NSC rules as well; confirm it for your version with the test further down. Not covered: code that implements its own X509TrustManager (the Android documentation classifies that as a risk in its own right), SDKs that ship their own TLS library in native code, and cross-platform frameworks that bring their own network stack. For every SDK in your dependency list, write down once which stack it uses to reach the network. The real scope of your pinning is whatever that list says.

Rotation as a runbook with a timeline

This is where pinning actually costs you. The rule fits in one sentence: the hash of the new key must be inside the versions in the field before that key goes on the server.

T-0        Generate the new key (or choose the new CA). ADD its hash to the
           pin set; remove nothing. Move the Android expiration date forward.
T-0        Ship an app release with that pin set (v5.4).
T+?        Wait until the share of active users on versions older than v5.4
           drops below the threshold you accept. The threshold is a business
           decision; write it down. Force-update old versions if needed.
T+N        Switch the server to the new key or CA. Watch pin failure telemetry.
T+N+1 day  If telemetry is quiet, the switch is done. Do not destroy the old key yet.
Next       Remove the old pin and add a NEW backup in its place. The set never
release    drops to a single element.

Do not guess the waiting period, measure it: the version distribution in your analytics gives you the adoption of the last six releases at two and four weeks. Enterprise distributions and devices stuck on old OS versions are the long tail of that curve.

Some events enter the calendar from outside. A CA can change its intermediate; if you pinned the root or your own key you are unaffected, and if you pinned the intermediate you find out that morning. Once certificate lifetimes reach 47 days, renewal is fully automated; if you pin the leaf, make sure your ACME client reuses the key, and verify that with a monitoring check that compares the hash after each renewal.

CDNs are their own topic. With certificates a CDN manages on your behalf, the key and the choice of CA belong to the provider and can change without notice. Cloudflare's documentation says so directly: the certificates they manage rotate regularly, the only viable route if you must pin is a custom certificate you upload, and you need to reuse the same key on renewal. With a different provider, ask the same question at contract time.

If you are already locked out

Order matters, because the first options take minutes and the last one takes days.

  1. If you still have the old key, put it back on the server. If the certificate expired, get a new certificate for the same key; you pinned the SPKI, so the app accepts it. Most lockouts end at this step, provided the old key was not deleted in the name of tidiness.
  2. If the key behind the backup pin is in the vault, issue a certificate for it and deploy that. This is the reason the backup pin exists.
  3. If you have remotely updatable pins, publish a new set (next section).
  4. On Android, if the expiration date is near, old versions reconnect on their own after it. NSPinnedDomains on iOS has no such exit.
  5. With none of the above, you ship an emergency release. If the endpoint that tells users to update lives on the pinned domain, you cannot show the message; keeping the force-update and status endpoints on a separate, unpinned domain is part of the runbook for that reason.

Remotely updatable pins and kill switches

Being able to update the pin set from the server frees a lockout from the release cycle. It creates a bootstrapping problem in return: over which channel, and trusting what, do you download the new set? Over the pinned channel, the update locks out at the same moment you do. Over an unpinned channel, whoever sits in the middle can send their own pin.

The way out is to separate transport security from content security. Download the pin set from an unpinned domain, and verify its signature with a public key embedded in the app. The signing key is independent of your TLS keys and stays offline.

import CryptoKit
import Foundation

struct SignedPinSet: Decodable {
    let payload: Data        // the PinSet as JSON
    let signature: Data      // ECDSA P-256 over payload, DER
}

struct PinSet: Decodable {
    let version: Int
    let notAfter: Date
    let pins: [String: Set<String>]
}

enum PinSetVerifier {
    static func verify(
        _ signed: SignedPinSet,
        currentVersion: Int,
        publicKeyX963: Data          // signing public key embedded in the app
    ) throws -> PinSet? {
        let key = try P256.Signing.PublicKey(x963Representation: publicKeyX963)
        let signature = try P256.Signing.ECDSASignature(derRepresentation: signed.signature)
        guard key.isValidSignature(signature, for: signed.payload) else { return nil }

        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        let set = try decoder.decode(PinSet.self, from: signed.payload)

        // Reject a replayed older set and an expired one
        guard set.version > currentVersion, set.notAfter > .now else { return nil }
        return set
    }
}

The rules: the pin set embedded in the app is always the starting value, and a remote set replaces it only with a valid signature and a higher version number. If the download fails, the app stays on what it has. An attacker can block the update and cannot make the app accept a forged set. On Android the same design means building a CertificatePinner from the verified set and refreshing the client through OkHttpClient.newBuilder(); NSC does not fit this scenario.

A kill switch, meaning a flag that turns pinning off remotely, is just the same signed structure with an empty pins field. Its benefit is a way out of a lockout within minutes. Its risk is the same sentence read differently: whoever obtains the signing key can turn pinning off for everyone. And switching pinning off with an unsigned remote config flag gives you the security level of having no pinning, with more code.

Telemetry: is it failing, is it an attack, is it your own rotation

A team that does not hear about pin failures learns about a lockout from store reviews. On every pin failure report the host, the app version, the OS, whether system evaluation passed, and the hashes of the chain the server presented. Send no user data; none is needed. Send the report to a separate, unpinned domain, because the pinned channel is not working at that moment.

NSPinnedDomains and NSC give you no such hook; the connection ends with a generic TLS error. With those two, the signals you have are the TLS error rate in your networking layer and a traffic drop on the server. If you want to see chain hashes you need the delegate, CertificatePinner or TrustKit.

Reading the data is not hard:

Observation Most likely
A sudden spike across all versions and regions right after a server change Your own rotation; go to the lockout section of the runbook
Failures only on versions below a certain release The rotation was right, the waiting period was too short
A low, steady rate on a small number of devices, with hashes belonging to well-known proxy tools Security researchers, curious users, networks doing corporate TLS inspection
System evaluation passed, an unfamiliar CA in the chain, many unrelated devices and networks Take it seriously: a mis-issued certificate or interference at network level

Put an alert on the first row: when the pin failure rate crosses a threshold, it pages whoever is on call.

Testing: proxy in debug, rejection in release

Two things need verifying separately: that developers can inspect traffic in debug builds, and that the release build really rejects an untrusted certificate. In teams that never verify the second, we find pinning has been off for months because a debug setting leaked into release or the pinned domain was misspelled.

On Android the debug side is handled by debug-overrides. On iOS there is no debug exception for NSPinnedDomains; use a separate Info.plist without pins for the Debug configuration, and check in CI that the release package contains them:

# iOS: does Info.plist inside the release .ipa contain the pin set
plutil -extract NSAppTransportSecurity.NSPinnedDomains json -o - Payload/App.app/Info.plist

# Android: the compiled NSC in the release APK (if resource paths were shortened,
# find the file's path inside the APK with `aapt2 dump resources` first)
aapt2 dump xmltree --file res/xml/network_security_config.xml app-release.apk

The behavioural test of the release build is manual and repeated for every release candidate: install the root certificate of mitmproxy, Charles or Proxyman on the device, enable full trust in Settings on iOS, and run the release build through the proxy. Every request to a pinned domain must fail, and the proxy should show nothing but failed handshakes. On Android, repeat this on a rooted emulator with the proxy root added to the system store; a root in the user store is rejected even without pinning, so that test does not measure pinning at all.

You can also lock the client wiring down with a unit test. With OkHttp 5's mockwebserver3 and okhttp-tls modules:

@Test
fun `connection is rejected when the pin does not match`() {
    val localhost = InetAddress.getByName("localhost").canonicalHostName
    val held = HeldCertificate.Builder().addSubjectAlternativeName(localhost).build()
    val serverCerts = HandshakeCertificates.Builder().heldCertificate(held).build()
    // We trust the certificate: standard verification passes, only the pin fails
    val clientCerts = HandshakeCertificates.Builder()
        .addTrustedCertificate(held.certificate)
        .build()

    val server = MockWebServer()
    server.useHttps(serverCerts.sslSocketFactory())
    server.enqueue(MockResponse())
    server.start()

    val client = OkHttpClient.Builder()
        .sslSocketFactory(clientCerts.sslSocketFactory(), clientCerts.trustManager)
        .certificatePinner(
            CertificatePinner.Builder()
                .add(server.hostName, "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
                .build()
        )
        .build()

    assertFailsWith<SSLPeerUnverifiedException> {
        client.newCall(Request.Builder().url(server.url("/")).build()).execute()
    }
    server.close()
}

The second half of the same test should supply the right pin with CertificatePinner.pin(held.certificate) and show the request succeeding. The iOS counterpart is an integration test that builds PinningDelegate with a wrong pin, sends a request to a real host and expects NSURLErrorCancelled.

How pinning is bypassed in a pentest

In OWASP MASTG's own words, for most applications pinning can be bypassed within seconds, as long as the app uses the APIs the tools know about. Defenders should know three routes.

The first is runtime hooking. On a rooted or jailbroken device Frida injects into the app's process and replaces the verification functions. objection reduces that to one command: android sslpinning disable and ios sslpinning disable. The stock scripts target well-known points such as SecTrustEvaluateWithError, X509TrustManager, OkHttp's CertificatePinner.check and TrustKit. To the extent you use the platform's standard APIs, those scripts work on you too, and that is no reason to abandon standard APIs.

The second is repackaging. The APK is unpacked, the pin-set in network_security_config.xml is deleted or trust in user CAs is added, and the app is re-signed. MASTG also describes the static route: find the hashes in the code and replace them with the proxy's hash. On non-jailbroken iOS devices the equivalent is embedding the Frida library into the app and re-signing it.

The third is reverse engineering a custom implementation. Moving the pin check into native code, obfuscating it and spreading it over several places defeats the stock scripts and moves the attacker's cost from hours to days. It does not move it from days to never.

The conclusion: pinning keeps no secrets from someone who controls the device. If your security depends on your protocol staying hidden, your problem starts before pinning. The layers that work are on the server:

  • Verifying on the server that a request comes from an unmodified app on a genuine device. A repackaged app gets past pinning and cannot produce a valid attestation; the details are in the App Attest and Play Integrity article.
  • Keeping business rules, prices, limits and authorisation on the server. No value sent by the client should be processed on the assumption that someone who sees the traffic cannot change it.
  • Using root, jailbreak and hooking detection as a risk signal sent to the server; used as a client-side block, it is switched off by the same Frida script.
  • Rate limiting and anomaly detection.

A pentest report that says "pinning bypassed with Frida" has not, on that alone, reported a finding. The finding is what could be done after the bypass.

Common mistakes

  • Skipping standard evaluation and comparing only pins, or carrying "trust every certificate" debug code into release.
  • A single pin. A set with no backup locks out at the first key change.
  • Pinning the certificate itself, or a leaf key managed by a CDN.
  • On iOS, declaring NSPinnedLeafIdentities and NSPinnedCAIdentities together in the belief that either one will do.
  • On iOS, hashing raw SecKeyCopyExternalRepresentation output and being surprised it does not match openssl.
  • On Android, writing the expiration date once and forgetting it; pinning switches itself off silently.
  • Changing the key on the server before the release carrying the new pin has spread.
  • Destroying the old key right after a rotation.
  • Pinning third-party domains.
  • Keeping force-update and status endpoints on the pinned domain.
  • Tying pinning to an unsigned remote config flag.
  • Never testing that the release build actually rejects.

Checklist

  • Every pinned domain has a written justification and an owner responsible for rotation.
  • SPKI is pinned; the set has at least two elements, one of them a backup kept offline or belonging to a second CA.
  • The pin level (leaf or CA) was chosen by who manages the certificate; CDN-managed keys are not pinned.
  • iOS uses NSPinnedDomains or a delegate that calls SecTrustEvaluateWithError first; its SPKI hash was compared with openssl output.
  • On Android the pin-set has an expiration that is reviewed every release; user CAs are trusted nowhere outside debug-overrides.
  • WebViews and SDKs with their own network stack have been listed as covered or not covered.
  • The rotation runbook is written down: app release first, adoption threshold, then the server; the old key is kept.
  • The lockout sequence is defined and the force-update endpoint is on an unpinned domain.
  • Remote pin updates, if any, are signed, versioned and fall back to the embedded set.
  • Pin failures are reported and the rate has an alert.
  • Every release candidate is tested in release configuration against a trusted proxy root, and pinned requests fail.
  • Attestation and business-rule checks exist on the server; security does not rest on the assumption that pinning cannot be bypassed.

Need support on this?

Let's review together whether these controls are set up correctly in your app. We're here with security audits and advisory.

Comments and questions

Disagree with something, or stuck on this in your own project? Write to us and let's talk.