🌏 閱讀中文版本
iOS SSL Pinning and Certificate Validation Guide
Introduction
iOS apps communicate with servers via HTTPS and trust system-built root Certificate Authorities (Root CAs) by default. However, this trust mechanism may be insufficient in these scenarios:
- Corporate Networks: Using self-signed certificates or internal CAs
- High Security Requirements: Financial, healthcare, and government apps
- Man-in-the-Middle Protection: Public Wi-Fi, malicious proxy servers
- Certificate Forgery Prevention: Attackers intercepting traffic with stolen or forged certificates
SSL Pinning (certificate pinning) embeds server certificates or public keys within the app. Even if the device’s trust chain is compromised, the app can still verify the server’s identity.
Core Concepts
SSL Pinning Types
1. Certificate Pinning – Validates the complete certificate – Pros: Strictest security – Cons: App must be updated when certificate renews
2. Public Key Pinning – Validates only the public key portion – Pros: No app update needed when certificate renews (public key unchanged) – Cons: Slightly more complex implementation
iOS Certificate Validation Mechanism
iOS handles certificate validation through the urlSession(_:didReceive:completionHandler:) method of URLSessionDelegate. Apps can intercept the default validation flow and add custom logic.
Prerequisites
Environment Requirements
- Xcode 14.0+
- iOS 13.0+
- Swift 5.5+
- Server SSL certificate (.cer or .crt format)
Obtaining Server Certificate
Method 1: Using OpenSSL
# Download certificate
openssl s_client -connect api.example.com:443 -showcerts < /dev/null |
openssl x509 -outform DER -out server.cer
# View certificate information
openssl x509 -in server.cer -inform DER -text -noout
Method 2: Using Browser
- Visit
https://api.example.comin Chrome/Safari - Click lock icon in address bar → View certificate
- Export as .cer format
Method 3: Using macOS Keychain
# Using security command
security find-certificate -c "api.example.com" -p > server.pem
# Convert to DER format
openssl x509 -in server.pem -outform DER -out server.cer
Extracting Public Key SHA-256 Fingerprint
# Extract public key from certificate and calculate SHA-256
openssl x509 -in server.cer -inform DER -pubkey -noout |
openssl pkey -pubin -outform DER |
openssl dgst -sha256 -binary |
base64
# Example output:
# 5Rw3Md4hN/7k3FJ8xK2eZpQ4L9nV6tY8sW1cX3bA5g0=
Implementation Steps
Step 1: Add Certificate to Project
- Drag
server.cerinto Xcode project - Ensure “Target Membership” is checked
- Certificate will be automatically added to App Bundle
Step 2: Implement URLSession Delegate
Create custom URLSessionDelegate to handle certificate validation:
import Foundation
import Security
class SSLPinningManager: NSObject, URLSessionDelegate {
// MARK: - Properties
/// Trusted certificate file names
private let trustedCertificates: [String]
/// Whether SSL Pinning is enabled
private let enablePinning: Bool
// MARK: - Initialization
init(certificates: [String], enablePinning: Bool = true) {
self.trustedCertificates = certificates
self.enablePinning = enablePinning
super.init()
}
// MARK: - URLSessionDelegate
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
// Only handle server trust validation
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
// If pinning is disabled, use default validation
guard enablePinning else {
completionHandler(.performDefaultHandling, nil)
return
}
// Get server trust object
guard let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Perform certificate pinning validation
if validateCertificate(serverTrust: serverTrust) {
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
// MARK: - Certificate Validation
/// Validate server certificate
private func validateCertificate(serverTrust: SecTrust) -> Bool {
// Load local trusted certificates
guard let localCertificates = loadCertificates() else {
print("❌ Failed to load local certificates")
return false
}
// Set validation policy (use default SSL policy)
let policy = SecPolicyCreateSSL(true, nil)
SecTrustSetPolicies(serverTrust, policy)
// Set anchor certificates (locally trusted certificates)
SecTrustSetAnchorCertificates(serverTrust, localCertificates as CFArray)
SecTrustSetAnchorCertificatesOnly(serverTrust, true)
// Evaluate trust chain
var error: CFError?
let isValid = SecTrustEvaluateWithError(serverTrust, &error)
if !isValid, let error = error {
print("❌ Certificate validation failed: (error.localizedDescription)")
}
return isValid
}
/// Load certificates from Bundle
private func loadCertificates() -> [SecCertificate]? {
var certificates: [SecCertificate] = []
for certName in trustedCertificates {
guard let certPath = Bundle.main.path(forResource: certName, ofType: "cer"),
let certData = try? Data(contentsOf: URL(fileURLWithPath: certPath)),
let certificate = SecCertificateCreateWithData(nil, certData as CFData) else {
print("❌ Failed to load certificate: (certName).cer")
continue
}
certificates.append(certificate)
}
return certificates.isEmpty ? nil : certificates
}
}
Step 3: Use Custom URLSession
import Foundation
class APIClient {
// MARK: - Properties
private let session: URLSession
private let baseURL: String
// MARK: - Initialization
init(baseURL: String, enableSSLPinning: Bool = true) {
self.baseURL = baseURL
// Configure SSL Pinning
let pinningManager = SSLPinningManager(
certificates: ["server"], // Without extension
enablePinning: enableSSLPinning
)
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 60
self.session = URLSession(
configuration: configuration,
delegate: pinningManager,
delegateQueue: nil
)
}
// MARK: - API Methods
func fetchData(endpoint: String) async throws -> Data {
guard let url = URL(string: baseURL + endpoint) else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard (200...299).contains(httpResponse.statusCode) else {
throw URLError(.init(rawValue: httpResponse.statusCode))
}
return data
}
}
Step 4: Practical Usage Example
import SwiftUI
struct ContentView: View {
@State private var result: String = "Waiting for request..."
@State private var isLoading: Bool = false
var body: some View {
VStack(spacing: 20) {
Text(result)
.padding()
Button("Test SSL Pinning") {
testSSLPinning()
}
.disabled(isLoading)
}
.padding()
}
private func testSSLPinning() {
isLoading = true
Task {
do {
let client = APIClient(
baseURL: "https://api.example.com",
enableSSLPinning: true
)
let data = try await client.fetchData(endpoint: "/users")
await MainActor.run {
result = "✅ Success: Received (data.count) bytes"
isLoading = false
}
} catch {
await MainActor.run {
result = "❌ Failed: (error.localizedDescription)"
isLoading = false
}
}
}
}
}
Public Key Pinning Implementation (Advanced)
Public key pinning is more flexible than certificate pinning. No app update needed when certificate renews.
extension SSLPinningManager {
/// Validate using public key SHA-256 fingerprints
func validatePublicKey(serverTrust: SecTrust, pinnedHashes: [String]) -> Bool {
// Get server certificate chain
guard let certificates = SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate] else {
return false
}
// Check each certificate's public key
for certificate in certificates {
guard let publicKey = SecCertificateCopyKey(certificate),
let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data? else {
continue
}
// Calculate SHA-256 fingerprint
let hash = publicKeyData.sha256().base64EncodedString()
// Compare with pinned public key fingerprints
if pinnedHashes.contains(hash) {
print("✅ Public key validation passed: (hash)")
return true
}
}
print("❌ Public key validation failed: No matching fingerprint")
return false
}
}
// MARK: - Data Extension
extension Data {
func sha256() -> Data {
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
self.withUnsafeBytes {
_ = CC_SHA256($0.baseAddress, CC_LONG(self.count), &hash)
}
return Data(hash)
}
}
Using public key pinning:
let pinningManager = SSLPinningManager(
certificates: [],
enablePinning: true
)
// In urlSession(_:didReceive:completionHandler:)
let pinnedHashes = [
"5Rw3Md4hN/7k3FJ8xK2eZpQ4L9nV6tY8sW1cX3bA5g0=",
"Ab1cD2eF3gH4iJ5kL6mN7oP8qR9sT0uV1wX2yZ3aB4c=" // Backup public key
]
if pinningManager.validatePublicKey(serverTrust: serverTrust, pinnedHashes: pinnedHashes) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
Testing and Validation
Test Valid Certificate
func testValidCertificate() async {
let client = APIClient(
baseURL: "https://api.example.com",
enableSSLPinning: true
)
do {
let data = try await client.fetchData(endpoint: "/health")
print("✅ Certificate validation passed, received: (data.count) bytes")
} catch {
print("❌ Request failed: (error)")
}
}
Test Invalid Certificate
Test if pinning is effective using a different server or expired certificate:
func testInvalidCertificate() async {
let client = APIClient(
baseURL: "https://wrong-server.com", // Use different server
enableSSLPinning: true
)
do {
_ = try await client.fetchData(endpoint: "/test")
print("❌ Warning: Validation should fail but passed")
} catch {
print("✅ Correct: Certificate validation failed - (error)")
}
}
Test with Charles Proxy
- Install Charles SSL certificate on device
- Enable SSL Proxying
- Run app and send requests
- Verify SSL Pinning blocks man-in-the-middle attacks
Expected Results: – Without Pinning: Request succeeds (can be intercepted) – With Pinning: Request fails (certificate validation fails)
Common Issues
Q1: App Can’t Connect After Certificate Update
Cause: With certificate pinning, server certificate updates cause validation failure.
Solutions: 1. Use public key pinning (public key usually unchanged) 2. Include multiple certificates in app (old + new) 3. Implement remote configuration for dynamic public key updates
Q2: How to Switch Between Development and Production
class AppConfig {
static var isProduction: Bool {
#if DEBUG
return false
#else
return true
#endif
}
static var enableSSLPinning: Bool {
// Disable pinning in development for easier testing
return isProduction
}
}
let client = APIClient(
baseURL: AppConfig.baseURL,
enableSSLPinning: AppConfig.enableSSLPinning
)
Q3: Will App Store Review Be Affected
SSL Pinning doesn’t affect review, but note: – Ensure app can connect to server normally – Provide test account and documentation – Avoid pinning Apple service certificates (e.g., iCloud)
Q4: How to Handle Certificate Chain Validation
Fully validate server certificate chain:
private func validateCertificateChain(serverTrust: SecTrust) -> Bool {
// Get certificate chain
guard let chain = SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate] else {
return false
}
print("📋 Certificate chain contains (chain.count) certificates")
// Check each certificate
for (index, cert) in chain.enumerated() {
if let summary = SecCertificateCopySubjectSummary(cert) as String? {
print(" [(index)] (summary)")
}
}
// Validate leaf certificate (server certificate)
if let leafCert = chain.first {
return validateLeafCertificate(leafCert)
}
return false
}
Q5: Performance Impact
SSL Pinning has minimal performance impact: – Certificate loading: Only once during initialization – Validation time: Adds < 10ms – Memory overhead: ~2-5KB per certificate
Best Practices
1. Use Certificate Backup
Include primary and backup certificates in app:
let pinningManager = SSLPinningManager(
certificates: [
"server-primary", // Primary certificate
"server-backup" // Backup certificate
],
enablePinning: true
)
2. Implement Fallback Mechanism
Provide fallback option when pinning fails:
class AdaptiveAPIClient {
private var sslPinningFailed = false
func fetchData(endpoint: String) async throws -> Data {
do {
// Try with SSL Pinning
let client = APIClient(baseURL: baseURL, enableSSLPinning: !sslPinningFailed)
return try await client.fetchData(endpoint: endpoint)
} catch {
// Log failure and retry (without Pinning)
if !sslPinningFailed {
sslPinningFailed = true
print("⚠️ SSL Pinning failed, falling back to standard validation")
return try await fetchData(endpoint: endpoint)
}
throw error
}
}
}
3. Logging and Monitoring
Log certificate validation results for troubleshooting:
func logCertificateInfo(serverTrust: SecTrust) {
guard let certificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
return
}
let summary = SecCertificateCopySubjectSummary(certificate) as String? ?? "Unknown"
let data = SecCertificateCopyData(certificate) as Data
let hash = data.sha256().base64EncodedString()
print("""
📋 Certificate Info:
Subject: (summary)
SHA-256: (hash)
Size: (data.count) bytes
""")
// Send to analytics service
Analytics.log(event: "ssl_pinning_validation", parameters: [
"certificate_hash": hash,
"result": "success"
])
}
4. Multi-Layer Validation
Combine certificate pinning with public key pinning:
func hybridValidation(serverTrust: SecTrust) -> Bool {
// Prefer certificate pinning
if validateCertificate(serverTrust: serverTrust) {
return true
}
// Fallback to public key pinning
if validatePublicKey(serverTrust: serverTrust, pinnedHashes: backupHashes) {
print("⚠️ Certificate validation failed, but public key validation passed")
return true
}
return false
}
Summary
iOS SSL Pinning implementation key points:
Core Technologies:
– Use URLSessionDelegate to intercept certificate validation
– Execute custom validation via SecTrust API
– Add certificates to App Bundle as trust anchors
Security Considerations: – Certificate Pinning: Strictest, but app update needed for certificate renewal – Public Key Pinning: More flexible, suitable for regular certificate renewals – Hybrid Validation: Combine both for fallback mechanism
Practical Recommendations: – Disable pinning in development for easier testing – Include backup certificates for emergencies – Implement remote configuration for dynamic validation strategy – Log validation results for troubleshooting
With proper SSL Pinning implementation, iOS apps can effectively prevent man-in-the-middle attacks and secure HTTPS communication. In high-security scenarios (financial, healthcare, government apps), SSL Pinning is an essential security measure.
Related Resources: – Apple Developer – URLSessionDelegate – Apple Developer – Certificate, Key, and Trust Services – OWASP – Certificate and Public Key Pinning
Related Articles
- Complete Guide to Importing SSL/TLS Certificates in AWS
- Complete Guide to Implementing SSL Pinning and Custom Trust Chain in Android
- Secure Guest Mode Data Recording in iOS and Android Applications
- Biometric Change Detection: Complete Implementation Guide for Android and iOS
- iOS vs Android Deep Link Complete Comparison Guide: In-Depth Analysis of Universal Links & App Links