iOS Security Implementation
iOS-specific security implementations using platform security APIs, Secure Enclave, and Swift security frameworks.
Overview
This guide covers iOS-specific security implementations. For security concepts and principles, see Security Overview. For general mobile security patterns, consult the cross-platform documentation.
Core Focus Areas:
- Keychain Services API for secure credential storage
- LocalAuthentication framework for Face ID/Touch ID
- URLSession certificate pinning with TrustKit
- Data Protection API for file encryption
- Secure Enclave integration
- Security Overview - Security principles and Zero Trust architecture
- Authentication - OAuth 2.0, JWT, biometric authentication strategies
- Data Protection - Encryption patterns, PII handling
- Input Validation - Validation concepts, injection prevention
- Security Testing - Testing security controls
Never store credentials in UserDefaults - always use Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly. Implement biometric authentication (Face ID/Touch ID) with LocalAuthentication framework. Use certificate pinning to prevent MITM attacks. Enable Data Protection API with .completeFileProtection for sensitive files.
Security Architecture
iOS Security Layers
Keychain Storage
Keychain Architecture
Keychain Manager Implementation
// Core/Storage/KeychainManager.swift
import Foundation
import Security
final class KeychainManager {
static let shared = KeychainManager()
private init() {}
// MARK: - Generic Methods
func save(_ value: String, forKey key: String) -> Bool {
guard let data = value.data(using: .utf8) else {
return false
}
// Delete any existing item first
delete(forKey: key)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
func get(forKey key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let value = String(data: data, encoding: .utf8) else {
return nil
}
return value
}
func delete(forKey key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
func deleteAll() -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
// MARK: - Banking-Specific Methods
func saveAuthToken(_ token: String) -> Bool {
save(token, forKey: KeychainKeys.authToken)
}
func getAuthToken() -> String? {
get(forKey: KeychainKeys.authToken)
}
func deleteAuthToken() -> Bool {
delete(forKey: KeychainKeys.authToken)
}
func savePIN(_ pin: String) -> Bool {
save(pin, forKey: KeychainKeys.userPIN)
}
func getPIN() -> String? {
get(forKey: KeychainKeys.userPIN)
}
func deletePIN() -> Bool {
delete(forKey: KeychainKeys.userPIN)
}
func saveEncryptionKey(_ key: String) -> Bool {
save(key, forKey: KeychainKeys.encryptionKey)
}
func getEncryptionKey() -> String? {
get(forKey: KeychainKeys.encryptionKey)
}
}
// MARK: - Keychain Keys
enum KeychainKeys {
static let authToken = "auth_token"
static let refreshToken = "refresh_token"
static let userPIN = "user_pin"
static let encryptionKey = "encryption_key"
static let deviceId = "device_id"
}
Keychain Accessibility Levels
| Accessibility | Description | Use Case |
|---|---|---|
WhenUnlocked | Accessible when device unlocked | General auth tokens |
WhenUnlockedThisDeviceOnly | Unlocked + not backed up | Recommended |
AfterFirstUnlock | After first unlock (persists across reboots) | Background sync tokens |
AfterFirstUnlockThisDeviceOnly | After first unlock + not backed up | Sensitive background data |
WhenPasscodeSetThisDeviceOnly | Requires device passcode | Highly sensitive (PINs) |
Always use ThisDeviceOnly variants to prevent iCloud Keychain sync. Sensitive credentials should never leave the device. iCloud Keychain backup creates a security risk - if a user's iCloud account is compromised, attackers gain access to synced credentials.
Biometric Authentication
Biometric Authentication Flow
Biometric Auth Manager
// Core/Security/BiometricAuth.swift
import LocalAuthentication
enum BiometricType {
case faceID
case touchID
case none
var displayName: String {
switch self {
case .faceID: return "Face ID"
case .touchID: return "Touch ID"
case .none: return "None"
}
}
}
final class BiometricAuthManager {
static let shared = BiometricAuthManager()
private init() {}
// MARK: - Properties
var biometricType: BiometricType {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
return .none
}
switch context.biometryType {
case .faceID:
return .faceID
case .touchID:
return .touchID
default:
return .none
}
}
var isBiometricsAvailable: Bool {
biometricType != .none
}
// MARK: - Authentication
func authenticate(reason: String) async throws -> Bool {
let context = LAContext()
// Configure context
context.localizedCancelTitle = "Cancel"
context.localizedFallbackTitle = "Enter PIN"
// Require biometrics (no fallback to device passcode)
context.biometryPolicy = .deviceOwnerAuthenticationWithBiometrics
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
if let error {
throw mapError(error)
}
throw BiometricError.notAvailable
}
do {
return try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason
)
} catch let error as LAError {
throw mapError(error)
}
}
// MARK: - Private Methods
private func mapError(_ error: Error) -> BiometricError {
guard let laError = error as? LAError else {
return .failed
}
switch laError.code {
case .userCancel:
return .userCancel
case .userFallback:
return .userFallback
case .biometryNotAvailable:
return .notAvailable
case .biometryNotEnrolled:
return .notEnrolled
case .biometryLockout:
return .lockout
case .authenticationFailed:
return .authenticationFailed
case .appCancel:
return .appCancel
case .invalidContext:
return .invalidContext
case .passcodeNotSet:
return .passcodeNotSet
default:
return .failed
}
}
}
// MARK: - Biometric Error
enum BiometricError: LocalizedError {
case notAvailable
case notEnrolled
case lockout
case userCancel
case userFallback
case authenticationFailed
case appCancel
case invalidContext
case passcodeNotSet
case failed
var errorDescription: String? {
switch self {
case .notAvailable:
return "Biometric authentication is not available on this device"
case .notEnrolled:
return "No biometric data enrolled. Please set up Face ID or Touch ID in Settings."
case .lockout:
return "Biometric authentication is locked due to too many failed attempts. Please try again later."
case .userCancel:
return "Authentication cancelled by user"
case .userFallback:
return "User chose to enter PIN"
case .authenticationFailed:
return "Biometric authentication failed. Please try again."
case .appCancel:
return "Authentication cancelled by app"
case .invalidContext:
return "Invalid authentication context"
case .passcodeNotSet:
return "Device passcode not set. Please set up a passcode in Settings."
case .failed:
return "Biometric authentication failed"
}
}
var isRecoverable: Bool {
switch self {
case .userCancel, .userFallback, .authenticationFailed:
return true
default:
return false
}
}
}
Biometric Auth View
// Presentation/Auth/BiometricAuthView.swift
import SwiftUI
struct BiometricAuthView: View {
@StateObject private var viewModel = BiometricAuthViewModel()
@EnvironmentObject var coordinator: AppCoordinator
var body: some View {
VStack(spacing: 32) {
// Logo
Image("BankLogo")
.resizable()
.scaledToFit()
.frame(width: 120, height: 120)
// Biometric icon
biometricIcon
.font(.system(size: 64))
.foregroundColor(.brandPrimary)
// Title
Text("Secure Login")
.font(.headlineLarge)
// Message
Text("Use \(viewModel.biometricType.displayName) to access your account")
.font(.bodyMedium)
.foregroundColor(.textSecondary)
.multilineTextAlignment(.center)
// Authenticate button
Button {
Task {
await viewModel.authenticate()
}
} label: {
HStack {
Image(systemName: viewModel.biometricIconName)
Text("Authenticate with \(viewModel.biometricType.displayName)")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isAuthenticating)
// PIN fallback
Button("Enter PIN Instead") {
coordinator.navigate(to: .pinEntry)
}
.buttonStyle(.bordered)
// Error message
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
.font(.bodySmall)
.foregroundColor(.error)
.multilineTextAlignment(.center)
}
}
.padding()
.onAppear {
Task {
await viewModel.authenticateAutomatically()
}
}
}
@ViewBuilder
private var biometricIcon: some View {
switch viewModel.biometricType {
case .faceID:
Image(systemName: "faceid")
case .touchID:
Image(systemName: "touchid")
case .none:
Image(systemName: "lock.shield")
}
}
}
// MARK: - ViewModel
@MainActor
class BiometricAuthViewModel: ObservableObject {
@Published private(set) var isAuthenticating = false
@Published var errorMessage: String?
let biometricType: BiometricType
let biometricIconName: String
init() {
self.biometricType = BiometricAuthManager.shared.biometricType
self.biometricIconName = biometricType == .faceID ? "faceid" : "touchid"
}
func authenticate() async {
isAuthenticating = true
errorMessage = nil
do {
let success = try await BiometricAuthManager.shared.authenticate(
reason: "Authenticate to access your account"
)
if success {
// Navigate to main screen
// coordinator.navigate(to: .main)
}
} catch let error as BiometricError {
errorMessage = error.errorDescription
if error == .userFallback {
// Navigate to PIN entry
// coordinator.navigate(to: .pinEntry)
}
} catch {
errorMessage = "An unexpected error occurred"
}
isAuthenticating = false
}
func authenticateAutomatically() async {
// Auto-trigger biometric prompt on view appear
await authenticate()
}
}
Certificate Pinning
Certificate pinning prevents man-in-the-middle (MITM) attacks by validating that the server's SSL certificate matches a known certificate embedded in the app. Standard HTTPS relies on the device's trust store - if an attacker installs a fraudulent root certificate on the device (via malware or physical access), the device will trust fraudulent certificates signed by that root. Certificate pinning bypasses the system trust store entirely by computing the SHA-256 hash of the server's certificate and comparing it against hardcoded hashes in the app. If the hashes don't match, the connection is rejected regardless of what the system trust store says. You should pin both the current certificate and a backup certificate to prevent outages when rotating certificates.
Certificate Pinning Architecture
Certificate Pinning Implementation
// Core/Security/CertificatePinning.swift
import Foundation
final class CertificatePinningDelegate: NSObject, URLSessionDelegate {
// SHA-256 hashes of pinned certificates (production)
private let pinnedCertificateHashes: Set<String> = [
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", // Primary cert
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=", // Backup cert
]
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
// Only handle server trust challenges
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
// Get server certificates
guard let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Get certificate data and compute SHA-256 hash
let serverCertificateData = SecCertificateCopyData(serverCertificate) as Data
let serverCertificateHash = sha256(data: serverCertificateData)
// Check if hash matches any pinned certificate
if pinnedCertificateHashes.contains(serverCertificateHash) {
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
} else {
// Certificate not pinned - reject connection
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
private func sha256(data: Data) -> String {
var hash = `UInt8`)
data.withUnsafeBytes {
_ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash)
}
return Data(hash).base64EncodedString()
}
}
// Usage in NetworkManager
extension NetworkManager {
static func createPinnedSession() -> URLSession {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 60
let delegate = CertificatePinningDelegate()
return URLSession(
configuration: configuration,
delegate: delegate,
delegateQueue: nil
)
}
}
Extract certificate hashes from your production API servers using:
openssl s_client -connect api.bank.com:443 -showcerts | openssl x509 -pubkey -noout | openssl rsa -pubin -outform der | openssl dgst -sha256 -binary | base64
Pin both primary and backup certificates to prevent outages during certificate rotation.
Data Protection
File Encryption with Data Protection API
// Core/Storage/SecureFileManager.swift
import Foundation
final class SecureFileManager {
static let shared = SecureFileManager()
private init() {}
func saveSecurely(_ data: Data, to filename: String) throws {
let fileURL = try getDocumentsDirectory().appendingPathComponent(filename)
try data.write(
to: fileURL,
options: [
.atomic,
.completeFileProtection // Encrypted when device locked
]
)
}
func loadSecurely(from filename: String) throws -> Data {
let fileURL = try getDocumentsDirectory().appendingPathComponent(filename)
return try Data(contentsOf: fileURL)
}
func deleteSecurely(_ filename: String) throws {
let fileURL = try getDocumentsDirectory().appendingPathComponent(filename)
if FileManager.default.fileExists(atPath: fileURL.path) {
try FileManager.default.removeItem(at: fileURL)
}
}
private func getDocumentsDirectory() throws -> URL {
try FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: false
)
}
}
Data Protection Levels
| Protection Level | Description | Use Case |
|---|---|---|
complete | Encrypted when locked | Most secure (auth tokens) |
completeUnlessOpen | Encrypted unless file open | Background file access |
completeUntilFirstUserAuthentication | Encrypted until first unlock | Background sync data |
none | No encryption | Public data only |
Secure Coding Practices
Input Validation
// Domain/Validation/PaymentValidator.swift
import Foundation
struct PaymentValidator {
static func validate(_ payment: Payment) throws {
// Amount validation
guard payment.amount > 0 else {
throw ValidationError.invalidAmount("Amount must be greater than zero")
}
guard payment.amount <= 1_000_000 else {
throw ValidationError.invalidAmount("Amount exceeds maximum limit")
}
// Account number validation (example format)
let accountNumberRegex = "^[0-9]{8,12}$"
guard payment.recipientAccountNumber.range(
of: accountNumberRegex,
options: .regularExpression
) != nil else {
throw ValidationError.invalidAccountNumber("Invalid account number format")
}
// Currency code validation
guard payment.currency.count == 3,
payment.currency.allSatisfy({ $0.isUppercase }) else {
throw ValidationError.invalidCurrency("Invalid currency code")
}
// Recipient name validation (no SQL injection)
guard !payment.recipientName.contains(where: { $0 == "'" || $0 == "\"" || $0 == ";" }) else {
throw ValidationError.invalidRecipientName("Invalid characters in recipient name")
}
}
}
enum ValidationError: LocalizedError {
case invalidAmount(String)
case invalidAccountNumber(String)
case invalidCurrency(String)
case invalidRecipientName(String)
var errorDescription: String? {
switch self {
case .invalidAmount(let message),
.invalidAccountNumber(let message),
.invalidCurrency(let message),
.invalidRecipientName(let message):
return message
}
}
}
Common Mistakes
Don't: Store Secrets in UserDefaults
// BAD: UserDefaults is NOT encrypted
UserDefaults.standard.set(authToken, forKey: "token")
UserDefaults.standard.set(pin, forKey: "pin")
// GOOD: Use Keychain
KeychainManager.shared.saveAuthToken(authToken)
KeychainManager.shared.savePIN(pin)
Don't: Log Sensitive Data
// BAD: Logs visible in Console and crash reports
print("Auth token: \(token)")
print("Account number: \(accountNumber)")
print("PIN: \(pin)")
// GOOD: Never log sensitive data
print("Auth successful")
print("Account loaded")
// Or use conditional logging
#if DEBUG
print("Account: \(accountNumber.mask())")
#endif
Don't: Use Weak Biometric Fallback
// BAD: Falls back to device passcode (weak!)
context.biometryPolicy = .deviceOwnerAuthentication
// GOOD: Require biometrics, fallback to app PIN
context.biometryPolicy = .deviceOwnerAuthenticationWithBiometrics
context.localizedFallbackTitle = "Enter PIN"
Code Review Checklist
Security (Block PR)
- No secrets in UserDefaults - All sensitive data in Keychain
- No secrets in logs - No account numbers, tokens, PINs logged
- No hardcoded credentials - API keys from config, not source code
- Certificate pinning enabled - For production API endpoints
- Biometric auth has fallback - PIN entry option available
- File encryption enabled -
.completeFileProtectionfor sensitive files - Input validation present - All user inputs validated
Watch For (Request Changes)
- Keychain accessibility level - Use
ThisDeviceOnlyvariants - Biometric error handling - Handle all LAError cases
- Certificate pin rotation plan - Pin backup certificates
- Sensitive data in memory - Clear auth tokens after use
- SQL injection prevention - Parameterized queries or validation
- URL validation - Validate deep link URLs before opening
Best Practices
- Keychain wrapper used - Centralized KeychainManager
- Biometric policy enforced - No automatic passcode fallback
- Secure file storage - Data Protection API used
- Session timeout - Auto-logout after inactivity
- PCI-DSS compliance - No card data stored locally
Further Reading
iOS Framework Guidelines
- iOS Overview - Project setup and security configuration
- iOS Data & Networking - Secure API communication and certificate pinning
- iOS Architecture - Secure architecture patterns
Security Guidelines
- Security Overview - Cross-platform security principles
- Authentication - Authentication patterns and strategies
- Authorization - Authorization and access control
- Data Protection - Encryption strategies and key management
- Input Validation - Input validation patterns
- Security Testing - Security testing approaches
Mobile-Specific Guidelines
- Mobile Overview - Cross-platform mobile security
External Resources
Summary
Key Takeaways
- Keychain for secrets - Never use UserDefaults for auth tokens, PINs
- Biometric authentication - Face ID/Touch ID for secure, quick access
- Certificate pinning - Prevent MITM attacks on API communication
- Data Protection API - Encrypt files at rest with
.completeFileProtection - Input validation - Validate all user inputs to prevent injection
- Secure logging - Never log sensitive data (tokens, PINs, account numbers)
- Session management - Auto-logout after inactivity
- PCI-DSS compliance - Follow payment card industry standards
- Fallback mechanisms - Biometric auth with PIN fallback
- Certificate rotation - Pin backup certificates for zero-downtime updates
Next Steps: Review iOS Architecture for secure architectural patterns and iOS Testing for security testing strategies.