Cybersecurity

Certificate Pinning: Implementation Guide for Mobile and Web Apps

Learn how to implement certificate pinning in mobile and web applications to prevent MITM attacks. Covers iOS, Android, and modern alternatives to deprecated HPKP.

By Inventive HQ Team

Certificate pinning is a defense that makes your app trust only a specific server certificate or public key instead of any certificate a trusted CA vouches for, so a rogue or compromised CA cannot forge a working man-in-the-middle certificate for your domain. In practice you extract the SHA-256 hash of the server's Subject Public Key Info (SPKI), embed it in the client, and reject any TLS connection whose certificate chain does not contain a matching key. Pin the intermediate CA or the public key (not the leaf), always ship at least one backup pin, and add a remote kill switch — those three choices are the difference between hardened defense and locking your entire user base out of the app. HTTP Public Key Pinning (HPKP) for websites has been dead since Chrome 72 in 2018; for the web you now use Certificate Transparency monitoring, CAA DNS records, and app-level pinning instead.

That's the summary an AI Overview will give you. What it can't show you is what actually happens on the wire when a pin check runs, the exact rotation timeline that keeps you from bricking your app, and a copy-paste rollout checklist. Those are below — the animated validation flow, the decision table for what to pin, working iOS/Android/React Native code, and a pre-ship checklist you can hand to a reviewer.

Understanding Certificate Pinning

The diagram below shows the single decision that separates standard TLS from pinned TLS: after the normal chain-of-trust check passes, a pinned client runs a second gate that compares the presented public key against a small allowlist you control. A valid certificate from an untrusted-by-you CA sails through the first gate and dies at the second.

Certificate pinning validation flow An animated flow showing a TLS certificate travelling from server to app, passing standard CA validation, then being checked against a small set of pinned public keys where a rogue certificate is rejected and a matching certificate is accepted. Server sends cert Gate 1 Standard CA chain validation Trusts 100+ CAs Rogue cert PASSES Gate 2 — Pin SHA-256(SPKI) in allowlist? 1-3 keys you control cert MATCH → Accept connection proceeds NO MATCH → Reject rogue cert killed here
┌─────────────────────────────────────────────────────────────────────────┐
│                    STANDARD TLS VALIDATION                               │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  ┌────────┐    certificate    ┌────────┐    validate    ┌────────────┐ │
│  │ Server │ ─────────────────►│  App   │ ──────────────►│ Trust Store│ │
│  └────────┘                   └────────┘                │ (100+ CAs) │ │
│                                                          └────────────┘ │
│  Problem: Any of 100+ CAs can issue valid certs for your domain        │
│  Risk: Compromised CA = MITM attack with valid certificate             │
│                                                                          │
├─────────────────────────────────────────────────────────────────────────┤
│                    WITH CERTIFICATE PINNING                              │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  ┌────────┐    certificate    ┌────────┐    validate    ┌────────────┐ │
│  │ Server │ ─────────────────►│  App   │ ──────────────►│ Pinned Keys│ │
│  └────────┘                   └────────┘    + match     │ (1-3 keys) │ │
│                                    │                     └────────────┘ │
│                                    │                                     │
│                               ┌────▼────┐                               │
│                               │ Reject  │ ← If cert doesn't match pins │
│                               └─────────┘                               │
│                                                                          │
│  Solution: Only accept specific certificates/keys you control           │
│  Benefit: Rogue CA certificates are rejected                            │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

What to Pin

TargetSecurityFlexibilityRecommendation
Leaf CertificateHighestLowestRequires app update on every cert renewal
Intermediate CAHighMediumRecommended - survives leaf rotation
Root CALowHighestToo broad - trusts all certs from CA
Public Key (SPKI)HighHighBest - survives cert renewal if key reused

Extracting Pin Hashes

Get Public Key Hash (SPKI)

# From a live server
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 | \
  base64

# Output: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

# From a certificate file
openssl x509 -in certificate.crt -pubkey -noout | \
  openssl pkey -pubin -outform DER | \
  openssl dgst -sha256 -binary | \
  base64

Get Intermediate CA Hash

# View the certificate chain
openssl s_client -connect api.example.com:443 -showcerts </dev/null 2>/dev/null

# Extract intermediate (certificate 1, not 0)
openssl s_client -connect api.example.com:443 -showcerts </dev/null 2>/dev/null | \
  awk '/-----BEGIN CERTIFICATE-----/{i++}i==2' | \
  openssl x509 -pubkey -noout | \
  openssl pkey -pubin -outform DER | \
  openssl dgst -sha256 -binary | \
  base64

iOS Implementation

Native URLSession

import Foundation
import Security
import CryptoKit

class PinnedURLSessionDelegate: NSObject, URLSessionDelegate {

    // SHA-256 hashes of pinned public keys (SPKI)
    private let pinnedPublicKeyHashes: Set<String> = [
        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",  // Primary
        "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="   // 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
        }

        // Validate certificate chain
        let policies = [SecPolicyCreateSSL(true, challenge.protectionSpace.host as CFString)]
        SecTrustSetPolicies(serverTrust, policies as CFArray)

        var error: CFError?
        guard SecTrustEvaluateWithError(serverTrust, &error) else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        // Check if any certificate in chain matches our pins
        let certificateCount = SecTrustGetCertificateCount(serverTrust)

        for i in 0..<certificateCount {
            guard let certificate = SecTrustGetCertificateAtIndex(serverTrust, i),
                  let publicKey = SecCertificateCopyKey(certificate),
                  let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data? else {
                continue
            }

            // Hash the public key
            let hash = SHA256.hash(data: publicKeyData)
            let hashBase64 = Data(hash).base64EncodedString()

            if pinnedPublicKeyHashes.contains(hashBase64) {
                completionHandler(.useCredential, URLCredential(trust: serverTrust))
                return
            }
        }

        // No pin matched - reject connection
        print("Certificate pinning failed for \(challenge.protectionSpace.host)")
        completionHandler(.cancelAuthenticationChallenge, nil)
    }
}

// Usage
let delegate = PinnedURLSessionDelegate()
let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)

let task = session.dataTask(with: URL(string: "https://api.example.com/data")!) { data, response, error in
    if let error = error {
        print("Request failed: \(error)")
        return
    }
    // Handle response
}
task.resume()
// In AppDelegate or early initialization
import TrustKit

func configureTrustKit() {
    let trustKitConfig: [String: Any] = [
        kTSKSwizzleNetworkDelegates: true,
        kTSKPinnedDomains: [
            "api.example.com": [
                kTSKEnforcePinning: true,
                kTSKPublicKeyHashes: [
                    "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
                    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
                ],
                kTSKIncludeSubdomains: true,
                kTSKReportUris: ["https://report.example.com/pinning"]
            ]
        ]
    ]

    TrustKit.initSharedInstance(withConfiguration: trustKitConfig)
}

Android Implementation

Network Security Config (Android 7+)

<!-- res/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="false">
        <domain includeSubdomains="true">api.example.com</domain>
        <pin-set expiration="2025-12-31">
            <!-- Primary pin -->
            <pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
            <!-- Backup pin (REQUIRED) -->
            <pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
        </pin-set>
        <trust-anchors>
            <certificates src="system"/>
        </trust-anchors>
    </domain-config>
</network-security-config>
<!-- AndroidManifest.xml -->
<application
    android:networkSecurityConfig="@xml/network_security_config"
    ...>
Advertisement

OkHttp CertificatePinner

import okhttp3.CertificatePinner
import okhttp3.OkHttpClient

val certificatePinner = CertificatePinner.Builder()
    .add(
        "api.example.com",
        "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
        "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
    )
    .add(
        "*.example.com",  // Wildcard support
        "sha256/CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC="
    )
    .build()

val client = OkHttpClient.Builder()
    .certificatePinner(certificatePinner)
    .build()

// Usage with Retrofit
val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .client(client)
    .addConverterFactory(GsonConverterFactory.create())
    .build()

React Native (with Fetch)

// Using react-native-ssl-pinning
import { fetch } from 'react-native-ssl-pinning';

const response = await fetch('https://api.example.com/data', {
  method: 'GET',
  timeoutInterval: 10000,
  sslPinning: {
    certs: ['cert1', 'cert2']  // Certificate files in assets
  }
});

// Or with public key hashes
const response = await fetch('https://api.example.com/data', {
  method: 'GET',
  pkPinning: true,
  sslPinning: {
    certs: [
      'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
      'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB='
    ]
  }
});

Web Applications

HPKP Deprecation and Alternatives

HPKP was deprecated due to risks. Modern alternatives:

┌─────────────────────────────────────────────────────────────────┐
│                  HPKP (Deprecated 2018)                         │
├─────────────────────────────────────────────────────────────────┤
│  Public-Key-Pins: pin-sha256="..."; max-age=5184000            │
│                                                                 │
│  Risks:                                                         │
│  • "HPKP Suicide" - lose keys = site becomes inaccessible      │
│  • "RansomPKP" - attacker pins their keys via XSS              │
│  • Complex rotation requiring careful planning                  │
│                                                                 │
├─────────────────────────────────────────────────────────────────┤
│              MODERN ALTERNATIVES                                │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. Certificate Transparency (CT) Monitoring                    │
│     - Monitor crt.sh or CT logs for unauthorized certs         │
│     - Alert on certificates you didn't request                 │
│                                                                 │
│  2. Expect-CT Header (Report-Only)                              │
│     Expect-CT: max-age=86400, report-uri="https://..."         │
│     - Requires CT compliance, reports violations               │
│                                                                 │
│  3. CAA DNS Records                                             │
│     example.com. CAA 0 issue "letsencrypt.org"                 │
│     - Restricts which CAs can issue for your domain            │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

CAA Records (DNS-Based Pinning)

; Only Let's Encrypt can issue certificates
example.com.    CAA 0 issue "letsencrypt.org"
example.com.    CAA 0 issuewild "letsencrypt.org"

; Report violations
example.com.    CAA 0 iodef "mailto:security@example.com"

Certificate Transparency Monitoring

# Query CT logs for your domain
curl "https://crt.sh/?q=%.example.com&output=json" | jq '.[].id'

# Set up monitoring with certspotter
pip install certspotter
certspotter --domain example.com --webhook https://alerts.example.com/ct

Pin Rotation Strategy

┌─────────────────────────────────────────────────────────────────┐
│                    PIN ROTATION TIMELINE                         │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  Month 1          Month 2          Month 3          Month 4     │
│  ────────────────────────────────────────────────────────────── │
│                                                                  │
│  [Pin A: Active]   [Pin A: Active]  [Pin A: Removed]            │
│  [Pin B: Backup]   [Pin B: Active]  [Pin B: Active]             │
│                    [Pin C: Added]   [Pin C: Backup]             │
│                                                                  │
│  Steps:                                                          │
│  1. Always have 2+ pins (active + backup)                       │
│  2. Add new backup pin BEFORE removing old pin                  │
│  3. Wait for app update propagation (2-4 weeks)                 │
│  4. Rotate active pin to use new certificate                    │
│  5. Remove old pin only after full propagation                  │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Automated Pin Rotation Check

#!/usr/bin/env python3
"""Check if pinned certificates are expiring soon."""

import ssl
import socket
from datetime import datetime, timedelta
import hashlib
import base64

def get_cert_pins(hostname, port=443):
    """Extract public key pins from server certificate chain."""
    context = ssl.create_default_context()
    pins = []

    with socket.create_connection((hostname, port)) as sock:
        with context.wrap_socket(sock, server_hostname=hostname) as ssock:
            cert_chain = ssock.getpeercert(binary_form=True)
            # Note: This gets leaf cert only; for full chain, use different approach

            cert = ssl.DER_cert_to_PEM_cert(cert_chain)
            # Extract and hash public key
            # (Simplified - use cryptography library for production)

    return pins

def check_pin_expiration(pins, warning_days=30):
    """Check if any pinned certificates are expiring soon."""
    alerts = []
    today = datetime.now()

    for pin in pins:
        days_until_expiry = (pin['expiration'] - today).days
        if days_until_expiry < warning_days:
            alerts.append({
                'pin': pin['hash'],
                'expires': pin['expiration'],
                'days_remaining': days_until_expiry
            })

    return alerts

# Check and alert
alerts = check_pin_expiration([
    {'hash': 'AAAA...', 'expiration': datetime(2025, 6, 1)},
    {'hash': 'BBBB...', 'expiration': datetime(2025, 12, 1)}
])

for alert in alerts:
    print(f"WARNING: Pin {alert['pin'][:20]}... expires in {alert['days_remaining']} days")

Emergency Bypass Mechanism

Include a kill switch for emergencies:

// iOS - Remote config based bypass
class PinningConfiguration {
    static let shared = PinningConfiguration()

    var isPinningEnabled: Bool {
        // Check remote config
        return RemoteConfig.shared.getBool("pinning_enabled", default: true)
    }

    var pinnedHashes: [String] {
        // Allow remote pin updates for emergencies
        if let remotePins = RemoteConfig.shared.getStringArray("pinned_keys") {
            return remotePins
        }
        return defaultPins
    }

    private let defaultPins = [
        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
        "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
    ]
}
// Android - Remote config based bypass
object PinningConfig {
    private val remoteConfig = FirebaseRemoteConfig.getInstance()

    val isPinningEnabled: Boolean
        get() = remoteConfig.getBoolean("pinning_enabled")

    val pins: List<String>
        get() {
            val remotePins = remoteConfig.getString("pinned_keys")
            return if (remotePins.isNotEmpty()) {
                remotePins.split(",")
            } else {
                defaultPins
            }
        }

    private val defaultPins = listOf(
        "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
        "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
    )
}

Testing Certificate Pinning

Verify Pinning Works

# Use mitmproxy or Charles Proxy to intercept traffic
# If pinning is working, the app should refuse to connect

# Android: Install proxy CA as user cert (won't be trusted for pinned domains)
# iOS: Trust proxy CA in Settings > General > About > Certificate Trust Settings

Debugging Pin Failures

// iOS - Detailed logging
func urlSession(_ session: URLSession,
                didReceive challenge: URLAuthenticationChallenge,
                completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {

    guard let serverTrust = challenge.protectionSpace.serverTrust else {
        print("No server trust available")
        completionHandler(.cancelAuthenticationChallenge, nil)
        return
    }

    let count = SecTrustGetCertificateCount(serverTrust)
    print("Certificate chain contains \(count) certificates")

    for i in 0..<count {
        if let cert = SecTrustGetCertificateAtIndex(serverTrust, i) {
            let summary = SecCertificateCopySubjectSummary(cert) as String? ?? "Unknown"
            print("Certificate \(i): \(summary)")

            if let publicKey = SecCertificateCopyKey(cert),
               let data = SecKeyCopyExternalRepresentation(publicKey, nil) as Data? {
                let hash = SHA256.hash(data: data)
                print("  Public key hash: \(Data(hash).base64EncodedString())")
            }
        }
    }

    // Continue with validation...
}

Pre-Ship Pinning Checklist

Run this before you release pinning to production. Every unchecked box is a plausible way to lock your users out.

  • Pin the SPKI, not the leaf certificate — survives certificate renewal when you reuse the key pair, so routine renewals don't require an app store release.
  • Ship at least one backup pin that is not currently in the served chain but whose key you already hold in reserve. A single pin plus an unplanned CA change equals a bricked app.
  • Backup pin generated from a key you actually control — pinning a hash you can't reproduce later is the same as having no backup.
  • Remote kill switch wired up (Firebase Remote Config, LaunchDarkly, your own flag endpoint) so you can disable pinning without waiting for app review.
  • Report-only / soft-fail rollout first — collect pin-mismatch telemetry for one release cycle before you enforce hard failures.
  • Expiration monitoring alerts at least 30 days before any pinned certificate expires.
  • Rotation runbook documented — add-new-backup → propagate → promote → retire-old, with the 2–4 week propagation wait written down.
  • Verified with a proxy (mitmproxy / Charles) that a MITM certificate is actually rejected, not silently accepted.
  • Failures logged, never swallowed — expected vs. received pins captured for security review.
  • Web domains covered separately — CAA records set and Certificate Transparency monitoring live, since HPKP is no longer an option.

Which approach should I use?

Your situationUse thisWhy
Mobile app you control the client forApp-level SPKI pinning + backup pinYou ship the client, so you own the pin lifecycle and can gate rollout
Public website / browser trafficCAA records + CT monitoringHPKP is dead (Chrome 72, 2018); you can't safely pin in the browser
High-value B2B API, both ends controlledmTLS instead of / alongside pinningMutual auth is stronger than one-way pinning when you own both sides
Need to rotate certs without app releasesPin the intermediate CALeaf rotates freely as long as the issuing intermediate stays pinned

Best Practices

  1. Always include backup pins - At least 2 pins to survive rotation
  2. Pin intermediate CA - Better balance of security and flexibility
  3. Use public key pinning - Survives certificate renewal with same key
  4. Implement kill switch - Remote config to disable in emergencies
  5. Monitor expiration - Alert well before pinned certs expire
  6. Test thoroughly - Use proxy tools to verify pinning works
  7. Plan rotation - Document pin rotation procedure
  8. Report failures - Log pinning failures for security monitoring
  9. Consider CT - Certificate Transparency as complementary protection
  10. Use CAA records - Restrict which CAs can issue for your domain

Next Steps

Frequently Asked Questions

What is certificate pinning and why is it important?

Certificate pinning is a security technique where an application only accepts specific certificates or public keys for a server, rather than trusting any certificate signed by a trusted CA. This prevents man-in-the-middle (MITM) attacks even when an attacker has obtained a valid certificate from a compromised or rogue CA. It's especially important for mobile apps handling sensitive data like banking, healthcare, or authentication.

What is the difference between certificate pinning and public key pinning?

Certificate pinning validates the entire certificate (including expiration date), requiring app updates when certificates rotate. Public key pinning validates only the public key, which typically remains constant across certificate renewals if you reuse the same key pair. Public key pinning is generally preferred as it's more resilient to certificate rotation. You can also pin intermediate CA keys for flexibility.

Why was HPKP (HTTP Public Key Pinning) deprecated?

HPKP was deprecated in Chrome 72 (2018) due to serious risks including "HPKP Suicide" (losing access to your own site if you lose pinned keys), "RansomPKP" attacks (attackers pinning their own keys), and the complexity of managing pin rotation. Modern alternatives include Certificate Transparency monitoring, Expect-CT headers, and application-level pinning for mobile apps where you control the client.

How do I implement certificate pinning in iOS/Swift?

In iOS, use URLSession's delegate method urlSession(_:didReceive:completionHandler:) to validate the server's certificate chain. Extract the public key from SecTrust, compare its SHA-256 hash against your pinned hashes, and call completionHandler with .cancelAuthenticationChallenge if it doesn't match. Libraries like TrustKit and Alamofire simplify this with declarative configuration.

How do I implement certificate pinning in Android?

Android supports native pinning via network_security_config.xml (Android 7+). Define pins using SHA-256 hashes of public keys: <pin digest="SHA-256">base64hash==</pin>. Include backup pins for rotation. For older Android versions or more control, use OkHttp's CertificatePinner class. Always include at least one backup pin to avoid lockouts during rotation.

What are the risks of implementing certificate pinning incorrectly?

Risks include app lockout (users can't connect if pins are wrong or certificates rotate unexpectedly), difficult debugging (pinning hides the real certificate from inspection tools), emergency recovery challenges (can't quickly bypass pinning if something breaks), and update dependency (requiring app store approval to fix pin issues). Always include backup pins and have a bypass mechanism for emergencies.

How do I get the SHA-256 hash of a certificate's public key for pinning?

Use OpenSSL to extract the public key and hash it: openssl s_client -connect domain.com:443 | openssl x509 -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256 -binary | base64. This outputs the base64-encoded SHA-256 hash of the Subject Public Key Info (SPKI). Pin this value in your app configuration.

Should I pin the leaf certificate, intermediate, or root CA?

Pin the intermediate CA certificate for the best balance of security and flexibility. Leaf pinning is most secure but requires app updates on every certificate renewal. Root CA pinning is too broad (trusts all certificates from that CA). Intermediate pinning lets you rotate leaf certificates without app updates while still limiting trust to certificates from your specific CA chain.

How do I handle certificate pinning failures gracefully?

Never silently ignore pinning failures—this defeats the security purpose. Log failures with details (expected vs received pins) for debugging. Show users a clear error explaining the connection isn't secure. Implement a kill switch (remote config flag) to disable pinning in emergencies without requiring app updates. Consider pinning in report-only mode during initial rollout.

What is Certificate Transparency and how does it relate to pinning?

Certificate Transparency (CT) is a public log system where CAs must record all issued certificates. Instead of pinning specific certificates, you can monitor CT logs for unauthorized certificates issued for your domain. CT provides similar MITM protection without the operational risks of pinning. Use Expect-CT headers to require CT compliance, and set up CT log monitoring to alert on suspicious certificates.

certificate pinningSSLTLSmobile securityMITM protectioniOS securityAndroid security