---
name: security-best-practices
description: "Expert security decisions for iOS/tvOS: when Keychain vs UserDefaults, certificate pinning trade-offs, API key protection strategies, and secure data lifecycle management. Use when storing sensitive data, implementing authentication, or hardening app security. Trigger keywords: Keychain, security, certificate pinning, encryption, API key, token storage, secure storage, biometric, jailbreak, data protection"
version: "3.0.0"
---
# Security Best Practices — Expert Decisions
Expert decision frameworks for iOS security choices. Claude knows Keychain APIs — this skill provides judgment calls for when security measures add value and implementation trade-offs.
---
## Decision Trees
### Storage Selection
```
What type of data?
├─ Credentials (passwords, tokens, secrets)
│ └─ Keychain (always)
│ kSecAttrAccessibleAfterFirstUnlock typical
│
├─ User preferences
│ └─ Is it sensitive? (e.g., PIN enabled)
│ ├─ YES → Keychain
│ └─ NO → UserDefaults is fine
│
├─ Large sensitive files
│ └─ File system + Data Protection
│ .completeFileProtection option
│
└─ Non-sensitive app state
└─ UserDefaults or files
No special protection needed
```
### Certificate Pinning Decision
```
What's your threat model?
├─ Consumer app, standard security
│ └─ Trust system CA validation
│ ATS (App Transport Security) is sufficient
│
├─ Financial/healthcare/enterprise
│ └─ Pin certificates
│ But plan for rotation!
│
├─ High-value target (banking, crypto)
│ └─ Pin public key (not certificate)
│ Survives cert renewal
│
└─ Internal enterprise app
└─ May need custom CA trust
ServerTrustManager with custom evaluator
```
**The trap**: Pinning without rotation plan. When cert expires, app stops working.
### API Key Protection Strategy
```
Who controls the server?
├─ You control backend
│ └─ Don't embed API keys in app
│ Authenticate users, server makes API calls
│
├─ Third-party API, user-specific
│ └─ OAuth flow
│ User authenticates, gets their own token
│
└─ Third-party API, app-level key
└─ Is key truly needed client-side?
├─ NO → Proxy through your backend
└─ YES → Obfuscate + attestation
Accept risk of extraction
```
### Keychain Access Level
```
When does data need to be accessible?
├─ Only when device unlocked
│ └─ kSecAttrAccessibleWhenUnlocked
│ Most secure for user-facing data
│
├─ Background refresh needed
│ └─ kSecAttrAccessibleAfterFirstUnlock
│ Accessible after first unlock until reboot
│
├─ Shared across apps (same team)
│ └─ kSecAttrAccessGroup + appropriate access level
│
└─ Must survive device restore
└─ kSecAttrSynchronizable = true
Syncs via iCloud Keychain
```
---
## NEVER Do
### Storage Mistakes
**NEVER** store credentials in UserDefaults:
```swift
// ❌ UserDefaults is NOT encrypted
UserDefaults.standard.set(token, forKey: "authToken")
// Readable with device backup, jailbreak, or debugging
// ✅ Always use Keychain for credentials
try KeychainManager.save(key: "authToken", data: tokenData)
```
**NEVER** hardcode secrets in code:
```swift
// ❌ Compiled into binary — trivially extractable
let apiKey = "sk_live_abc123xyz789"
// ❌ Still in binary as string
let apiKey = String(format: "%@%@", "sk_live_", "abc123xyz789")
// ✅ Fetch from secure backend after authentication
let apiKey = try await secureConfigService.getAPIKey()
// Or at minimum, obfuscate + accept risk
let apiKey = Obfuscator.decode(encodedKey)
```
**NEVER** log sensitive data:
```swift
// ❌ Logs are accessible and persisted
print("User token: \(token)")
logger.debug("Password: \(password)")
// ✅ Never log credentials
logger.info("User authenticated successfully")
// If debugging, redact
#if DEBUG
logger.debug("Token: \(String(repeating: "*", count: token.count))")
#endif
```
### Keychain Mistakes
**NEVER** use kSecAttrAccessibleAlways:
```swift
// ❌ Accessible even before device unlocked — rarely needed
let query: [String: Any] = [
kSecAttrAccessible as String: kSecAttrAccessibleAlways
]
// ✅ Use appropriate access level
let query: [String: Any] = [
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
```
**NEVER** ignore Keychain errors:
```swift
// ❌ Silently fails — credential may not be saved
_ = SecItemAdd(query as CFDictionary, nil)
// ✅ Check status and handle errors
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
```
### Certificate Pinning Mistakes
**NEVER** pin without expiration handling:
```swift
// ❌ App breaks when certificate expires
let pinnedCert = loadBundledCertificate()
if serverCert != pinnedCert {
completionHandler(.cancelAuthenticationChallenge, nil)
}
// ✅ Pin public key (survives renewal) or have rotation plan
let pinnedPublicKey = loadBundledPublicKey()
let serverPublicKey = extractPublicKey(from: serverCert)
if pinnedPublicKey != serverPublicKey {
completionHandler(.cancelAuthenticationChallenge, nil)
}
```
**NEVER** disable ATS for convenience:
```swift
// ❌ Disables all transport security
// Info.plist
NSAppTransportSecurity
NSAllowsArbitraryLoads
// ✅ Only exception if absolutely needed, with justification
NSExceptionDomains
legacy-api.example.com
NSExceptionAllowsInsecureHTTPLoads
NSExceptionMinimumTLSVersion
TLSv1.2
```
### Memory Safety
**NEVER** keep credentials in memory longer than needed:
```swift
// ❌ Password stays in memory
class LoginManager {
var currentPassword: String? // May persist in memory
}
// ✅ Clear sensitive data immediately after use
func authenticate(password: String) async throws {
defer {
// Can't truly clear String, but can clear Data
// For true secure handling, use Data and zero it
}
let result = try await authService.login(password: password)
}
```
---
## Essential Patterns
### Keychain Manager
```swift
final class KeychainManager {
enum KeychainError: Error {
case itemNotFound
case duplicateItem
case unexpectedStatus(OSStatus)
}
static func save(key: String, data: Data, accessibility: CFString = kSecAttrAccessibleAfterFirstUnlock) throws {
// Delete existing item first (upsert pattern)
try? delete(key: key)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: accessibility
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
}
static func load(key: String) throws -> Data {
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 != errSecItemNotFound else {
throw KeychainError.itemNotFound
}
guard status == errSecSuccess, let data = result as? Data else {
throw KeychainError.unexpectedStatus(status)
}
return data
}
static func delete(key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unexpectedStatus(status)
}
}
}
```
### Public Key Pinning
```swift
final class PublicKeyPinningDelegate: NSObject, URLSessionDelegate {
private let pinnedPublicKeys: [SecKey]
init(publicKeyHashes: [String]) {
// Load pinned public keys from bundle
self.pinnedPublicKeys = publicKeyHashes.compactMap { hash in
// Convert hash to SecKey
loadPublicKey(hash: hash)
}
}
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Evaluate trust
guard SecTrustEvaluateWithError(serverTrust, nil) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Extract server's public key
guard let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, 0),
let serverPublicKey = SecCertificateCopyKey(serverCertificate) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Check if server's public key matches any pinned key
let matched = pinnedPublicKeys.contains { pinnedKey in
serverPublicKey == pinnedKey
}
if matched {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}
```
### Secure Configuration
```swift
enum SecureConfig {
// Environment-specific (via xcconfig, not hardcoded)
static var apiBaseURL: String {
guard let url = Bundle.main.object(forInfoDictionaryKey: "API_BASE_URL") as? String else {
fatalError("API_BASE_URL not configured")
}
return url
}
// Fetched from backend after authentication
static func fetchSecrets() async throws -> AppSecrets {
// User must be authenticated first
guard let authToken = try? KeychainManager.load(key: "authToken") else {
throw ConfigError.notAuthenticated
}
// Fetch from secure endpoint
var request = URLRequest(url: URL(string: "\(apiBaseURL)/config/secrets")!)
request.setValue("Bearer \(String(data: authToken, encoding: .utf8)!)", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(AppSecrets.self, from: data)
}
}
```
---
## Quick Reference
### Storage Selection Matrix
| Data Type | Storage | Protection Level |
|-----------|---------|------------------|
| Auth tokens | Keychain | AfterFirstUnlock |
| Passwords | Keychain | WhenUnlocked |
| Biometric secret | Keychain | WhenPasscodeSetThisDeviceOnly |
| User preferences | UserDefaults | None needed |
| Sensitive files | Files | .completeFileProtection |
| Cache | Files/Cache | None needed |
### Keychain Access Levels
| Level | When Accessible | Use Case |
|-------|-----------------|----------|
| WhenUnlocked | Device unlocked | User-facing credentials |
| AfterFirstUnlock | After first unlock | Background operations |
| WhenPasscodeSetThisDeviceOnly | With passcode, this device | Biometric-protected |
| Always | Always | Almost never use |
### Security Audit Checklist
- [ ] Credentials in Keychain, not UserDefaults
- [ ] No hardcoded secrets in code
- [ ] No sensitive data in logs
- [ ] HTTPS only (ATS enabled)
- [ ] Certificate/public key pinning (if high-value)
- [ ] Appropriate Keychain access levels
- [ ] Files use Data Protection
- [ ] Clear sensitive data from memory
### Red Flags
| Smell | Problem | Fix |
|-------|---------|-----|
| Token in UserDefaults | Not encrypted | Keychain |
| API key in source | Easily extracted | Backend proxy or obfuscate |
| NSAllowsArbitraryLoads = true | No transport security | Proper ATS config |
| kSecAttrAccessibleAlways | Over-permissive | Appropriate access level |
| Ignoring SecItem status | Silent failures | Check and handle errors |
| Pinning certificate, not public key | Breaks on renewal | Pin public key |
| Sensitive data in logs | Exposure risk | Never log credentials |