
Security
- 376 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Harden Swift and Apple platform apps before release by addressing Keychain usage, entitlements, data protection, ATS, and App Store privacy requirements.
About
Apple platform security skill guiding Keychain storage, entitlements, sandboxing, ATS, privacy manifests, and secure Swift patterns before App Store submission. Helps teams pass review and reduce data-exposure risk on iOS, macOS, and related targets.
- Keychain and secrets handling
- Entitlements and sandbox review
- Data protection and ATS
- Privacy manifest alignment
- Pre-submission security checklist
Security by the numbers
- 376 all-time installs (skills.sh)
- +18 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #569 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill securityAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 376 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Harden Swift and Apple platform apps before release by addressing Keychain usage, entitlements, data protection, ATS, and App Store privacy requirements.
Files
Privacy Manifests
Advisory skill for implementing Apple's privacy manifest requirements. Privacy manifests (PrivacyInfo.xcprivacy) became mandatory for App Store submissions in Spring 2024. This skill covers the manifest file format, required reason APIs, tracking declarations, third-party SDK privacy, and App Tracking Transparency.
When This Skill Activates
Use this skill when the user:
- Asks about "privacy manifest" or "PrivacyInfo.xcprivacy"
- Mentions "required reason API" or App Store privacy rejection
- Needs to declare "tracking domains" or "NSPrivacyTracking"
- Asks about "App Tracking Transparency" or ATT
- Wants to audit third-party SDK privacy declarations
- Is preparing an app for App Store submission and mentions privacy
- Gets an App Store Connect warning about missing privacy manifests
- Asks about "privacy nutrition labels" or App Store privacy details
Decision Tree
| Problem | Section |
|---|---|
| Creating PrivacyInfo.xcprivacy from scratch | Privacy Manifest File Format + Required Reason APIs |
| App Store rejection about required reason APIs | Required Reason APIs (match APIs in your code) |
| Declaring tracking domains | Tracking Domains and NSPrivacyTracking |
| Third-party SDK privacy | Third-Party SDK Declarations |
| Implementing ATT | App Tracking Transparency |
| Filling out App Store privacy labels | Privacy Nutrition Labels |
| Generating Xcode report | Xcode Privacy Report |
Review Process
1. Scan the Project
Glob: **/PrivacyInfo.xcprivacy
Grep: "NSPrivacyAccessedAPITypes|NSPrivacyTracking|NSPrivacyTrackingDomains"
Grep: "NSFileCreationDate|NSFileModificationDate|NSURLCreationDateKey"
Grep: "systemUptime|ProcessInfo.*processInfo"
Grep: "volumeAvailableCapacity|URLResourceKey.*volume"
Grep: "UserDefaults"
Grep: "activeInputModes|UITextInputMode"
Grep: "ATTrackingManager|requestTrackingAuthorization"2. Determine What Is Missing and Apply Fixes
Check: Does PrivacyInfo.xcprivacy exist? Are all required reason APIs declared? Is NSPrivacyTracking correct? Are tracking domains listed? Do third-party SDKs have their own manifests? Use the sections below to generate correct entries.
---
Privacy Manifest File Format
The privacy manifest is a property list named PrivacyInfo.xcprivacy. Add it via File > New > File > App Privacy in Xcode. Four required top-level keys:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
</dict>
</plist>Where to Place the File
- App targets: Add PrivacyInfo.xcprivacy to the root of the app bundle. In Xcode, ensure it is included in the target's "Copy Bundle Resources" build phase.
- Frameworks/SDKs: Place PrivacyInfo.xcprivacy in the root of the framework bundle.
- Swift packages: Place PrivacyInfo.xcprivacy in the package's resource bundle and declare it in Package.swift:
// Package.swift
.target(
name: "MyLibrary",
resources: [
.copy("PrivacyInfo.xcprivacy")
]
)---
Required Reason APIs
Apple requires you to declare why your app uses certain system APIs. You must include at least one valid reason code for each API category your app uses.
File Timestamp APIs (NSPrivacyAccessedAPICategoryFileTimestamp)
APIs that access file creation or modification dates.
| Reason Code | Description |
|---|---|
| DDA9.1 | Display file timestamps to the user |
| C617.1 | Access timestamps inside the app container, app group container, or CloudKit container |
| 3B52.1 | Access timestamps of files or directories the user specifically granted access to (document picker, drag and drop) |
| 0A2A.1 | Access timestamps for files managed by the app itself (third-party SDK only) |
Common triggers: NSFileCreationDate, NSFileModificationDate, NSURLContentModificationDateKey, NSURLCreationDateKey, getattrlist, stat, fstat.
System Boot Time APIs (NSPrivacyAccessedAPICategorySystemBootTime)
APIs that read how long the system has been running.
| Reason Code | Description |
|---|---|
| 35F9.1 | Measure elapsed time between events within the app |
| 8FFB.1 | Calculate absolute timestamps for events (e.g., events that occurred before app launch) |
| 3D61.1 | Access system boot time for the purposes of user-facing functionality |
Common triggers: systemUptime, ProcessInfo.processInfo.systemUptime, mach_absolute_time, clock_gettime(CLOCK_MONOTONIC).
Disk Space APIs (NSPrivacyAccessedAPICategoryDiskSpace)
APIs that query available or total disk space.
| Reason Code | Description |
|---|---|
| E174.1 | Display disk space to the user |
| 85F4.1 | Check whether there is sufficient disk space to write files |
| AB6B.1 | Query disk space for the app's own functionality, do not send off device |
| 7D9E.1 | Query disk space for the app's own functionality, results sent off device (e.g., analytics) |
Common triggers: volumeAvailableCapacityKey, volumeAvailableCapacityForImportantUsageKey, volumeAvailableCapacityForOpportunisticUsageKey, volumeTotalCapacityKey, statfs, statvfs.
User Defaults APIs (NSPrivacyAccessedAPICategoryUserDefaults)
APIs that read or write to UserDefaults.
| Reason Code | Description |
|---|---|
| CA92.1 | Read/write data accessible only to the app itself |
| 1C8F.1 | Read/write data accessible to app groups (shared UserDefaults) |
| C56D.1 | Read/write data from a third-party SDK for the SDK's own functionality |
| AC6B.1 | Read data from UserDefaults to retrieve a configuration set by an MDM (managed device) |
Common triggers: UserDefaults, NSUserDefaults, standardUserDefaults.
Active Keyboards API (NSPrivacyAccessedAPICategoryActiveKeyboards)
APIs that enumerate installed keyboards.
| Reason Code | Description |
|---|---|
| 3EC4.1 | Customize the app's UI based on active keyboards (e.g., supporting specific languages) |
| 54BD.1 | A custom keyboard app accessing active keyboards to implement its functionality |
Common triggers: UITextInputMode.activeInputModes, activeInputModes.
Declaring Required Reason APIs in the Manifest
Each entry has the category identifier and an array of reason codes. See "Common Patterns" below for a full XML example.
---
Tracking Domains and NSPrivacyTracking
NSPrivacyTracking declares whether your app tracks users. Apple defines tracking as: linking user/device data from your app with data from other companies' apps, websites, or offline properties for advertising, or sharing data with data brokers.
Apple does not consider these to be tracking: on-device-only data linking, fraud detection, security, or compliance.
If NSPrivacyTracking is true:
- You must implement App Tracking Transparency (ATT) before any tracking occurs
- List all tracking domains in
NSPrivacyTrackingDomains(the system blocks them until ATT is granted)
<key>NSPrivacyTracking</key>
<true/>
<key>NSPrivacyTrackingDomains</key>
<array>
<string>analytics.example.com</string>
<string>tracker.adnetwork.com</string>
</array>If your app does not track users, set NSPrivacyTracking to false and leave the domains array empty.
---
Third-Party SDK Declarations
SDK Privacy Manifests
Starting Spring 2024, apps that include commonly used third-party SDKs must ensure those SDKs provide their own PrivacyInfo.xcprivacy files. Apple publishes a list of SDKs that require privacy manifests and signatures, including Alamofire, FBSDKCoreKit, Firebase, Google Analytics, Kingfisher, Lottie, Realm, SDWebImage, and many others. See Apple's full list.
SDK Signatures
Third-party XCFrameworks should be signed by their developer. Prefer SDKs distributed through Swift Package Manager (signatures verified automatically). For XCFrameworks, verify the signature matches the expected developer.
Auditing Third-Party SDKs
Glob: **/Pods/**/PrivacyInfo.xcprivacy
Glob: **/.build/**/PrivacyInfo.xcprivacy
Glob: **/Carthage/**/PrivacyInfo.xcprivacy
Grep: "pod '|.package(url:"If a third-party SDK lacks a privacy manifest and uses required reason APIs, contact the SDK maintainer. As a temporary workaround, declare the SDK's API usage in your app's manifest, but this is not a long-term solution.
---
App Tracking Transparency
When ATT Is Required
You must present the ATT prompt if:
- NSPrivacyTracking is true in your privacy manifest
- Your app links user data with third-party data for advertising
- You use advertising identifiers (IDFA) for tracking purposes
You do not need ATT for:
- First-party analytics that stay on-device or on your own servers without linking to third-party data
- SKAdNetwork attribution (privacy-preserving, no user-level data)
- Fraud detection or security purposes
Implementation
Request permission after the app becomes active (not during app launch):
import AppTrackingTransparency
func requestTrackingPermission() {
ATTrackingManager.requestTrackingAuthorization { status in
switch status {
case .authorized:
// IDFA available via ASIdentifierManager.shared().advertisingIdentifier
break
case .denied, .restricted:
// Do not track
break
case .notDetermined:
break
@unknown default:
break
}
}
}Info.plist Requirement
<key>NSUserTrackingUsageDescription</key>
<string>We use this identifier to show you relevant ads and measure ad performance.</string>SKAdNetwork for Attribution
SKAdNetwork provides privacy-preserving install attribution without user-level data. It does not require ATT permission. Register ad network identifiers in Info.plist:
<key>SKAdNetworkItems</key>
<array>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>example123.skadnetwork</string>
</dict>
</array>---
Privacy Nutrition Labels
The privacy manifest feeds into the App Store privacy labels ("nutrition labels") displayed on your app's product page. The connection works as follows:
| Manifest Key | App Store Privacy Label |
|---|---|
| NSPrivacyCollectedDataTypes | "Data Used to Track You" and "Data Linked to You" sections |
| NSPrivacyTracking | Determines if "Data Used to Track You" section appears |
| NSPrivacyAccessedAPITypes | Not directly shown, but required for submission |
NSPrivacyCollectedDataTypes Format
Each entry declares: data type, whether it is linked to user identity, whether it is used for tracking, and purposes.
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeEmailAddress</string>
<key>NSPrivacyCollectedDataTypeLinked</key> <!-- linked to user identity? -->
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key> <!-- used for tracking? -->
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>Common data types: EmailAddress, Name, PhoneNumber, Location, DeviceID, CrashData, PerformanceData, ProductInteraction, PurchaseHistory (prefix each with NSPrivacyCollectedDataType).
Common purposes: AppFunctionality, Analytics, ThirdPartyAdvertising, ProductPersonalization (prefix each with NSPrivacyCollectedDataTypePurpose).
The App Store privacy label must match your manifest declarations. Inconsistencies trigger review delays or rejections.
---
Xcode Privacy Report
Generate a consolidated report via Product > Generate Privacy Report (or from the Organizer after archiving). Xcode produces a PDF listing all privacy manifest entries from the app and every embedded framework/SDK. Use this to verify all required reason APIs are declared, confirm third-party SDKs include their own manifests, and cross-check collected data types before filling out App Store Connect privacy labels.
---
Top Mistakes
1. Missing PrivacyInfo.xcprivacy entirely -- Every App Store submission requires a privacy manifest. Without one, expect a warning or rejection.
2. Using UserDefaults without declaring it -- Almost every app uses UserDefaults. Declare NSPrivacyAccessedAPICategoryUserDefaults with reason CA92.1.
- ❌
UserDefaults.standard.set(true, forKey: "onboardingComplete")with no manifest entry - ✅ Add CA92.1 declaration for data accessible only to the app
3. Wrong reason code -- Each code has a specific allowed use. Using DDA9.1 (display to user) when you never show timestamps in the UI will cause rejection. Use C617.1 for app container access instead.
4. Forgetting third-party SDK manifests -- Your app's manifest does not cover SDKs. Each must provide its own. SDKs on Apple's list without manifests will flag your submission.
5. NSPrivacyTracking true without ATT -- If you declare tracking, you must implement the ATT prompt before any tracking occurs. Missing this causes rejection.
6. Stale privacy nutrition labels -- After updating your manifest, also update the App Store Connect privacy labels. Inconsistencies trigger review issues.
---
Review Checklist
- [ ] PrivacyInfo.xcprivacy exists in the app target's bundle resources
- [ ] NSPrivacyTracking set correctly (true only if the app tracks users)
- [ ] NSPrivacyTrackingDomains lists all tracking domains (if tracking is true)
- [ ] All required reason APIs declared with valid reason codes (file timestamps, boot time, disk space, UserDefaults, active keyboards)
- [ ] Reason codes match actual API usage (not just any valid code)
- [ ] NSPrivacyCollectedDataTypes lists all collected data
- [ ] Third-party SDKs include their own PrivacyInfo.xcprivacy and are signed
- [ ] ATT prompt implemented if NSPrivacyTracking is true
- [ ] NSUserTrackingUsageDescription in Info.plist if ATT is used
- [ ] App Store Connect privacy labels match manifest declarations
- [ ] Xcode privacy report generated and reviewed (Product > Generate Privacy Report)
- [ ] Swift packages declare PrivacyInfo.xcprivacy in their resource bundle
Common Patterns
Typical App (No Tracking)
Most apps need at minimum UserDefaults declared. Start from the minimal manifest structure above and populate NSPrivacyAccessedAPITypes:
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
</array>App with Tracking and Ad Attribution
Add to the template above: set NSPrivacyTracking to true, list tracking domains, add NSPrivacyCollectedDataTypes entries (see "Privacy Nutrition Labels" for format), implement ATT, and add NSPrivacyAccessedAPICategoryDiskSpace with reason 7D9E.1 if sending disk metrics off-device.
---
References
Biometric Authentication
Implementing Face ID, Touch ID, and Optic ID across Apple platforms.
Overview
| Platform | Biometric Types |
|---|---|
| iOS | Face ID, Touch ID |
| macOS | Touch ID (on supported Macs) |
| watchOS | Wrist detection (not LAContext) |
| visionOS | Optic ID |
Required Setup
Info.plist
You must include a usage description for Face ID:
<key>NSFaceIDUsageDescription</key>
<string>Unlock your data securely with Face ID</string>Without this, the app will crash when requesting Face ID.
LAContext Basics
Checking Biometric Availability
import LocalAuthentication
final class BiometricAuthManager {
enum BiometricType {
case none
case touchID
case faceID
case opticID
var displayName: String {
switch self {
case .none: return "Passcode"
case .touchID: return "Touch ID"
case .faceID: return "Face ID"
case .opticID: return "Optic ID"
}
}
}
enum AuthError: Error {
case biometryNotAvailable
case biometryNotEnrolled
case biometryLockout
case userCancel
case userFallback
case systemCancel
case authenticationFailed
case unknown(Error)
}
static let shared = BiometricAuthManager()
private let context = LAContext()
// MARK: - Availability
var biometricType: BiometricType {
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
return .none
}
switch context.biometryType {
case .none:
return .none
case .touchID:
return .touchID
case .faceID:
return .faceID
case .opticID:
return .opticID
@unknown default:
return .none
}
}
var isBiometricAvailable: Bool {
var error: NSError?
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
}
var canUseBiometricOrPasscode: Bool {
var error: NSError?
return context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error)
}
func checkBiometricStatus() -> Result<BiometricType, AuthError> {
var error: NSError?
let canEvaluate = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
if canEvaluate {
return .success(biometricType)
}
guard let laError = error as? LAError else {
return .failure(.unknown(error ?? NSError()))
}
switch laError.code {
case .biometryNotAvailable:
return .failure(.biometryNotAvailable)
case .biometryNotEnrolled:
return .failure(.biometryNotEnrolled)
case .biometryLockout:
return .failure(.biometryLockout)
default:
return .failure(.unknown(laError))
}
}
}Authentication Patterns
Basic Biometric Authentication
extension BiometricAuthManager {
/// Authenticate with biometrics only (no passcode fallback shown)
func authenticateWithBiometrics(reason: String) async -> Result<Void, AuthError> {
let context = LAContext()
// Disable fallback button
context.localizedFallbackTitle = ""
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
return .failure(mapError(error))
}
do {
let success = try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason
)
return success ? .success(()) : .failure(.authenticationFailed)
} catch {
return .failure(mapError(error))
}
}
/// Authenticate with biometrics, falling back to device passcode
func authenticateWithBiometricsOrPasscode(reason: String) async -> Result<Void, AuthError> {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
return .failure(mapError(error))
}
do {
let success = try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: reason
)
return success ? .success(()) : .failure(.authenticationFailed)
} catch {
return .failure(mapError(error))
}
}
private func mapError(_ error: Error?) -> AuthError {
guard let laError = error as? LAError else {
return .unknown(error ?? NSError())
}
switch laError.code {
case .biometryNotAvailable:
return .biometryNotAvailable
case .biometryNotEnrolled:
return .biometryNotEnrolled
case .biometryLockout:
return .biometryLockout
case .userCancel:
return .userCancel
case .userFallback:
return .userFallback
case .systemCancel:
return .systemCancel
case .authenticationFailed:
return .authenticationFailed
default:
return .unknown(laError)
}
}
}Biometric-Protected Keychain Items
The most secure pattern: store data in Keychain with biometric access control.
extension KeychainManager {
/// Save data that requires biometric authentication to access
func saveBiometricProtected(
_ data: Data,
for account: String,
requireBiometry: Bool = true
) throws {
var error: Unmanaged<CFError>?
// Create access control
var flags: SecAccessControlCreateFlags = [.privateKeyUsage]
if requireBiometry {
flags.insert(.biometryCurrentSet) // Invalidates if biometry changes
}
guard let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
flags,
&error
) else {
throw error!.takeRetainedValue()
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessControl as String: accessControl
]
// Delete existing item first
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
}
/// Read data that requires biometric authentication
func readBiometricProtected(account: String, prompt: String) throws -> Data {
let context = LAContext()
context.localizedReason = prompt
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecUseAuthenticationContext as String: context
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
if status == errSecItemNotFound {
throw KeychainError.itemNotFound
}
throw KeychainError.unexpectedStatus(status)
}
return data
}
}SwiftUI Integration
Biometric Auth View Modifier
import SwiftUI
struct BiometricLockModifier: ViewModifier {
@State private var isUnlocked = false
@State private var showError = false
@State private var errorMessage = ""
let reason: String
let onUnlock: () -> Void
func body(content: Content) -> some View {
Group {
if isUnlocked {
content
} else {
lockedView
}
}
.task {
await authenticate()
}
.alert("Authentication Failed", isPresented: $showError) {
Button("Try Again") {
Task { await authenticate() }
}
Button("Cancel", role: .cancel) {}
} message: {
Text(errorMessage)
}
}
private var lockedView: some View {
VStack(spacing: 20) {
Image(systemName: biometricIcon)
.font(.system(size: 60))
.foregroundStyle(.secondary)
Text("Authentication Required")
.font(.headline)
Button("Unlock with \(biometricName)") {
Task { await authenticate() }
}
.buttonStyle(.borderedProminent)
}
}
private var biometricIcon: String {
switch BiometricAuthManager.shared.biometricType {
case .faceID: return "faceid"
case .touchID: return "touchid"
case .opticID: return "opticid"
case .none: return "lock"
}
}
private var biometricName: String {
BiometricAuthManager.shared.biometricType.displayName
}
private func authenticate() async {
let result = await BiometricAuthManager.shared.authenticateWithBiometricsOrPasscode(reason: reason)
await MainActor.run {
switch result {
case .success:
isUnlocked = true
onUnlock()
case .failure(let error):
switch error {
case .userCancel, .systemCancel:
break // Don't show error for user cancellation
default:
errorMessage = error.localizedDescription
showError = true
}
}
}
}
}
extension View {
func biometricLock(reason: String, onUnlock: @escaping () -> Void = {}) -> some View {
modifier(BiometricLockModifier(reason: reason, onUnlock: onUnlock))
}
}
// Usage
struct SecureContentView: View {
var body: some View {
Text("Secret Content")
.biometricLock(reason: "Access your secure notes")
}
}Re-authentication on App Become Active
import SwiftUI
@Observable
final class AppLockManager {
var isLocked = false
private var backgroundTime: Date?
private let lockTimeout: TimeInterval = 60 // Lock after 60 seconds in background
func handleBackgroundTransition() {
backgroundTime = Date()
}
func handleForegroundTransition() {
guard let backgroundTime else { return }
let elapsed = Date().timeIntervalSince(backgroundTime)
if elapsed > lockTimeout {
isLocked = true
}
self.backgroundTime = nil
}
}
struct ContentView: View {
@Environment(AppLockManager.self) private var lockManager
var body: some View {
Group {
if lockManager.isLocked {
LockScreenView()
} else {
MainAppView()
}
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)) { _ in
lockManager.handleBackgroundTransition()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
lockManager.handleForegroundTransition()
}
}
}Access Control Flags
Common Patterns
| Flag | Meaning |
|---|---|
.biometryAny | Any enrolled biometric works |
.biometryCurrentSet | Only current biometric enrollment (more secure) |
.devicePasscode | Device passcode required |
.userPresence | Biometric OR passcode |
.privateKeyUsage | For Secure Enclave keys |
✅ Recommended Combinations
// Require current biometric (re-enrollment invalidates)
[.biometryCurrentSet]
// Biometric with passcode fallback
[.userPresence]
// Secure Enclave key with biometric protection
[.privateKeyUsage, .biometryCurrentSet]❌ Avoid
// Too permissive - any biometric works, even if user adds new fingerprint
[.biometryAny]Error Handling Best Practices
User-Friendly Error Messages
extension BiometricAuthManager.AuthError: LocalizedError {
var errorDescription: String? {
switch self {
case .biometryNotAvailable:
return "Biometric authentication is not available on this device."
case .biometryNotEnrolled:
return "No biometric data is enrolled. Please set up Face ID or Touch ID in Settings."
case .biometryLockout:
return "Biometric authentication is locked due to too many failed attempts. Please use your passcode."
case .userCancel:
return "Authentication was cancelled."
case .userFallback:
return "Passcode authentication requested."
case .systemCancel:
return "Authentication was cancelled by the system."
case .authenticationFailed:
return "Authentication failed. Please try again."
case .unknown(let error):
return error.localizedDescription
}
}
}Handling Lockout
func handleBiometricLockout() async {
// After lockout, user must authenticate with passcode first
let result = await BiometricAuthManager.shared.authenticateWithBiometricsOrPasscode(
reason: "Unlock with passcode to reset biometric"
)
if case .success = result {
// Biometric is now reset, can try again
}
}macOS Considerations
Touch ID on Mac
#if os(macOS)
extension BiometricAuthManager {
var hasTouchID: Bool {
var error: NSError?
let context = LAContext()
let canEvaluate = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
return canEvaluate && context.biometryType == .touchID
}
}
#endifApple Watch Unlock (macOS)
// Check if Apple Watch can be used for authentication
let context = LAContext()
context.localizedReason = "Authenticate with Apple Watch"
// This will show Apple Watch as an option if available
try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason)Checklist
Setup
- [ ]
NSFaceIDUsageDescriptionin Info.plist - [ ] Clear, user-friendly reason strings
- [ ] Biometric availability check before showing options
Implementation
- [ ] Using
.biometryCurrentSetfor high-security items - [ ] Proper fallback to passcode when appropriate
- [ ] Error handling for all LAError cases
- [ ] User-friendly error messages
Security
- [ ] Keychain items with biometric access control for sensitive data
- [ ] Re-authentication after app returns from background
- [ ] No storing biometric results (always re-authenticate)
- [ ] Handling biometric lockout gracefully
UX
- [ ] Showing appropriate biometric icon (Face ID vs Touch ID)
- [ ] Explaining why biometric is needed
- [ ] Providing alternative authentication methods
- [ ] Not forcing biometric if user prefers passcode
Network Security
Securing network communication on Apple platforms with ATS, TLS, and certificate pinning.
App Transport Security (ATS)
ATS enforces secure connections by default on iOS 9+ and macOS 10.11+.
Default Behavior
By default, ATS requires:
- HTTPS (TLS 1.2 or later)
- Forward secrecy ciphers
- Valid certificates from trusted CAs
✅ Good: Trust Default ATS
<!-- No ATS configuration needed - defaults are secure -->
<!-- Just use https:// URLs in your code -->// ATS will enforce HTTPS automatically
let url = URL(string: "https://api.example.com/data")!⚠️ Exception: Specific Domain Needs HTTP
Only use when connecting to legacy servers you don't control:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>legacy-server.example.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.0</string>
</dict>
</dict>
</dict>❌ Never Do This in Production
<!-- DANGEROUS: Disables ATS entirely -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>This will likely cause App Store rejection and exposes users to MITM attacks.
ATS for Local Development
Allow localhost during development only:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>Better approach - use build configurations:
<!-- In Debug.xcconfig -->
ATS_LOCALHOST_EXCEPTION = true
<!-- In Release.xcconfig -->
ATS_LOCALHOST_EXCEPTION = falseCertificate Pinning
Pin certificates or public keys to prevent MITM attacks even with compromised CAs.
When to Use Certificate Pinning
| Scenario | Recommendation |
|---|---|
| Banking/financial apps | Required |
| Healthcare apps | Required |
| Apps handling PII | Strongly recommended |
| General consumer apps | Recommended |
| Apps using third-party APIs | Optional (can't pin their certs) |
Public Key Pinning (Recommended)
Public key pinning survives certificate rotation better than certificate pinning.
import Foundation
import CryptoKit
final class CertificatePinningDelegate: NSObject, URLSessionDelegate {
// SHA256 hashes of your server's public key(s)
// Include backup pins for key rotation
private let pinnedKeyHashes: 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 the certificate chain
var error: CFError?
let isValid = SecTrustEvaluateWithError(serverTrust, &error)
guard isValid else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Check if any certificate in the chain matches our pins
let certificateCount = SecTrustGetCertificateCount(serverTrust)
for index in 0..<certificateCount {
guard let certificate = SecTrustGetCertificateAtIndex(serverTrust, index) else {
continue
}
if let publicKeyHash = publicKeyHash(for: certificate),
pinnedKeyHashes.contains(publicKeyHash) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
return
}
}
// No matching pin found
completionHandler(.cancelAuthenticationChallenge, nil)
}
private func publicKeyHash(for certificate: SecCertificate) -> String? {
guard let publicKey = SecCertificateCopyKey(certificate) else {
return nil
}
var error: Unmanaged<CFError>?
guard let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? else {
return nil
}
// Add ASN.1 header for RSA 2048 or EC P-256 public key
let hash = SHA256.hash(data: publicKeyData)
return Data(hash).base64EncodedString()
}
}
// Usage
let pinningDelegate = CertificatePinningDelegate()
let session = URLSession(configuration: .default, delegate: pinningDelegate, delegateQueue: nil)Extracting Public Key Hash
Get the SHA256 hash of your server's public key:
# From a certificate file
openssl x509 -in server.crt -pubkey -noout | \
openssl pkey -pubin -outform DER | \
openssl dgst -sha256 -binary | \
base64
# From a live server
echo | openssl s_client -connect api.example.com:443 2>/dev/null | \
openssl x509 -pubkey -noout | \
openssl pkey -pubin -outform DER | \
openssl dgst -sha256 -binary | \
base64Certificate Pinning with TrustKit
For production apps, consider using TrustKit (open source library):
// Package.swift dependency
.package(url: "https://github.com/datatheorem/TrustKit.git", from: "3.0.0")import TrustKit
// Configure at app launch
let trustKitConfig: [String: Any] = [
kTSKPinnedDomains: [
"api.example.com": [
kTSKPublicKeyHashes: [
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
],
kTSKEnforcePinning: true,
kTSKIncludeSubdomains: true
]
]
]
TrustKit.initSharedInstance(withConfiguration: trustKitConfig)Secure URLSession Configuration
Production Configuration
final class SecureNetworkClient {
static let shared = SecureNetworkClient()
private let session: URLSession
private init() {
let configuration = URLSessionConfiguration.default
// Timeouts
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 300
// Disable caching for sensitive requests
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.urlCache = nil
// Require modern TLS
configuration.tlsMinimumSupportedProtocolVersion = .TLSv12
// Disable cookies if not needed
configuration.httpCookieAcceptPolicy = .never
configuration.httpShouldSetCookies = false
session = URLSession(configuration: configuration)
}
func request(_ url: URL) async throws -> Data {
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw NetworkError.invalidResponse
}
return data
}
}Sensitive Request Headers
extension URLRequest {
mutating func addSecureHeaders(token: String) {
// Authentication
setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
// Prevent caching
setValue("no-cache, no-store, must-revalidate", forHTTPHeaderField: "Cache-Control")
setValue("no-cache", forHTTPHeaderField: "Pragma")
setValue("0", forHTTPHeaderField: "Expires")
// Content type
setValue("application/json", forHTTPHeaderField: "Content-Type")
setValue("application/json", forHTTPHeaderField: "Accept")
}
}❌ Anti-patterns
Disabling Certificate Validation
// NEVER DO THIS IN PRODUCTION
class InsecureDelegate: NSObject, URLSessionDelegate {
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
// DANGEROUS: Accepts any certificate, enables MITM attacks
if let trust = challenge.protectionSpace.serverTrust {
completionHandler(.useCredential, URLCredential(trust: trust))
}
}
}Logging Sensitive Data
// NEVER log tokens, passwords, or sensitive request/response data
print("Auth token: \(token)") // ❌
print("Request body: \(requestBody)") // ❌
print("Response: \(responseData)") // ❌
// OK: Log non-sensitive metadata
print("Request to: \(url.host ?? "unknown")") // ✅
print("Status code: \(statusCode)") // ✅Hardcoded API Keys
// NEVER hardcode API keys in source
let apiKey = "sk-ant-api03-xxxxx" // ❌
// Store in Keychain or use environment/config
let apiKey = try KeychainManager.shared.readString(account: "api_key") // ✅Secure Data Transmission
Encrypting Request Bodies
For extremely sensitive data, encrypt before transmission:
import CryptoKit
func encryptPayload(_ data: Data, using key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.seal(data, using: key)
return sealedBox.combined!
}
func decryptPayload(_ data: Data, using key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.SealedBox(combined: data)
return try AES.GCM.open(sealedBox, using: key)
}Preventing Replay Attacks
struct SecureRequest {
let payload: Data
let timestamp: TimeInterval
let nonce: String
init(payload: Data) {
self.payload = payload
self.timestamp = Date().timeIntervalSince1970
self.nonce = UUID().uuidString
}
var isExpired: Bool {
let age = Date().timeIntervalSince1970 - timestamp
return age > 300 // 5 minute window
}
func sign(with key: SymmetricKey) -> Data {
let message = payload + String(timestamp).data(using: .utf8)! + nonce.data(using: .utf8)!
let signature = HMAC<SHA256>.authenticationCode(for: message, using: key)
return Data(signature)
}
}Background Session Security
// Background sessions need careful security consideration
let backgroundConfig = URLSessionConfiguration.background(withIdentifier: "com.app.background")
// Discretionary allows system to optimize for battery/network
backgroundConfig.isDiscretionary = true
// Require wifi for large downloads (optional)
backgroundConfig.allowsCellularAccess = false
// Sessions survive app termination - be careful with sensitive data
backgroundConfig.sessionSendsLaunchEvents = trueWebSocket Security
import Foundation
let wsURL = URL(string: "wss://api.example.com/socket")! // Always use wss://
let webSocketTask = URLSession.shared.webSocketTask(with: wsURL)
// Ping to keep connection alive and detect disconnects
func schedulePing() {
webSocketTask.sendPing { error in
if let error = error {
print("Ping failed: \(error)")
}
DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
schedulePing()
}
}
}Checklist
App Transport Security
- [ ] No
NSAllowsArbitraryLoadsin production - [ ] Domain exceptions are documented and justified
- [ ] All API endpoints use HTTPS
- [ ] TLS 1.2 or higher required
Certificate Pinning
- [ ] Pinning implemented for high-security apps
- [ ] Backup pins configured for key rotation
- [ ] Pin validation happens for entire certificate chain
- [ ] Graceful handling of pin validation failures
URLSession Configuration
- [ ] Appropriate timeouts configured
- [ ] Caching disabled for sensitive requests
- [ ] Cookies disabled if not needed
- [ ] Modern TLS version enforced
General
- [ ] No sensitive data in request logs
- [ ] No hardcoded API keys or tokens
- [ ] SSL validation never disabled in production
- [ ] Error messages don't leak sensitive info
Platform-Specific Security
Security considerations unique to iOS, macOS, and watchOS.
iOS Security
Data Protection Classes
iOS encrypts files using the device passcode. Choose the right class:
| Class | Constant | Accessible When |
|---|---|---|
| Complete Protection | .complete | Device unlocked only |
| Protected Unless Open | .completeUnlessOpen | Can finish write when locked |
| Protected Until First Auth | .completeUntilFirstUserAuthentication | After first unlock |
| No Protection | .none | Always (avoid for sensitive data) |
// Set file protection
let sensitiveURL = documentsURL.appendingPathComponent("secrets.json")
try sensitiveData.write(to: sensitiveURL, options: [.completeFileProtection])
// Core Data with protection
let storeDescription = NSPersistentStoreDescription()
storeDescription.setOption(
FileProtectionType.complete as NSObject,
forKey: NSPersistentStoreFileProtectionKey
)App Groups and Keychain Sharing
Sharing data between apps and extensions requires careful security:
// Keychain sharing between app and extension
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccessGroup as String: "TEAM_ID.com.company.shared",
kSecAttrService as String: "SharedCredentials",
kSecAttrAccount as String: "user_token",
kSecValueData as String: tokenData,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]Security considerations:
- [ ] Only share what's necessary between app and extensions
- [ ] Use
ThisDeviceOnlyaccessibility for shared items - [ ] Validate data received from shared containers
- [ ] Don't store highly sensitive data in App Groups (use Keychain)
Jailbreak Detection
For high-security apps (banking, enterprise):
func isDeviceCompromised() -> Bool {
#if targetEnvironment(simulator)
return false
#else
// Check for common jailbreak artifacts
let suspiciousPaths = [
"/Applications/Cydia.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/bin/bash",
"/usr/sbin/sshd",
"/etc/apt",
"/private/var/lib/apt/"
]
for path in suspiciousPaths {
if FileManager.default.fileExists(atPath: path) {
return true
}
}
// Check if app can write outside sandbox
let testPath = "/private/jailbreak_test.txt"
do {
try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
try FileManager.default.removeItem(atPath: testPath)
return true
} catch {
// Expected - can't write outside sandbox
}
// Check for suspicious URL schemes
if let url = URL(string: "cydia://package/com.example.package"),
UIApplication.shared.canOpenURL(url) {
return true
}
return false
#endif
}Warning: Determined attackers can bypass jailbreak detection. Use as defense-in-depth, not sole protection.
iOS Background Security
// Blur sensitive content when app enters background
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
private var blurView: UIVisualEffectView?
func sceneWillResignActive(_ scene: UIScene) {
guard let window = (scene as? UIWindowScene)?.windows.first else { return }
let blur = UIBlurEffect(style: .regular)
blurView = UIVisualEffectView(effect: blur)
blurView?.frame = window.bounds
blurView?.autoresizingMask = [.flexibleWidth, .flexibleHeight]
window.addSubview(blurView!)
}
func sceneDidBecomeActive(_ scene: UIScene) {
blurView?.removeFromSuperview()
blurView = nil
}
}Export Compliance
If your app uses encryption, declare it in App Store Connect:
| Encryption Use | Export Compliance |
|---|---|
| HTTPS only (URLSession) | Exempt |
| Standard iOS encryption APIs | Exempt (usually) |
| Custom cryptography | May require documentation |
| Strong encryption for non-exempt purposes | ERN required |
macOS Security
Sandboxing
macOS apps should be sandboxed when possible:
<!-- Entitlements.plist -->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- Only request what you need -->
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>Sandbox entitlements to audit:
| Entitlement | Risk Level | Justification Needed |
|---|---|---|
files.user-selected.read-write | Low | User grants access |
files.downloads.read-write | Medium | Document why |
network.client | Low | Common for apps |
network.server | Medium | Document why |
files.all | High | Strong justification |
temporary-exception.* | High | Migration plan needed |
Hardened Runtime
Required for notarization:
<!-- Entitlements.plist -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<false/>
<key>com.apple.security.cs.allow-jit</key>
<false/>
<key>com.apple.security.cs.disable-library-validation</key>
<false/>Avoid these unless absolutely necessary:
allow-unsigned-executable-memory- JIT compilers onlydisable-library-validation- Loading third-party pluginsallow-dyld-environment-variables- Rarely needed
XPC Service Security
Secure helper processes with XPC:
// In XPC service
import Foundation
@objc protocol SecureServiceProtocol {
func performSensitiveOperation(completion: @escaping (Bool, Error?) -> Void)
}
class SecureService: NSObject, SecureServiceProtocol {
func performSensitiveOperation(completion: @escaping (Bool, Error?) -> Void) {
// Validate the calling app
guard validateCaller() else {
completion(false, ServiceError.unauthorized)
return
}
// Perform operation
completion(true, nil)
}
private func validateCaller() -> Bool {
// Check code signature of calling process
// Implementation depends on your security requirements
return true
}
}Keychain Access on macOS
macOS Keychain has different behavior:
// macOS may prompt user for Keychain access
// Use kSecUseDataProtectionKeychain for iOS-like behavior (macOS 10.15+)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecUseDataProtectionKeychain as String: true // iOS-compatible keychain
]Code Signing Validation
Validate code signatures of helper tools:
import Security
func validateCodeSignature(at url: URL, expectedTeamID: String) -> Bool {
var staticCode: SecStaticCode?
let status = SecStaticCodeCreateWithPath(url as CFURL, [], &staticCode)
guard status == errSecSuccess, let code = staticCode else {
return false
}
var requirement: SecRequirement?
let requirementString = "anchor apple generic and identifier \"com.company.helper\" and certificate leaf[subject.OU] = \"\(expectedTeamID)\""
guard SecRequirementCreateWithString(requirementString as CFString, [], &requirement) == errSecSuccess,
let req = requirement else {
return false
}
return SecStaticCodeCheckValidity(code, [], req) == errSecSuccess
}watchOS Security
HealthKit Data Protection
Health data is extremely sensitive:
import HealthKit
let healthStore = HKHealthStore()
// Request only what you need
let readTypes: Set<HKObjectType> = [
HKObjectType.quantityType(forIdentifier: .heartRate)!
]
healthStore.requestAuthorization(toShare: nil, read: readTypes) { success, error in
// Handle authorization
}
// Never log health data
// Never transmit without user consent
// Always explain why health data is neededWatch Connectivity Security
Data synced between iPhone and Watch:
import WatchConnectivity
// Sensitive data should be transferred securely
func sendCredentialsToWatch(_ credentials: Credentials) {
guard WCSession.default.isReachable else { return }
// Encrypt before sending
let encryptedData = try? encrypt(credentials.encoded())
WCSession.default.sendMessageData(encryptedData ?? Data(), replyHandler: nil) { error in
print("Failed to send: \(error)")
}
}Security considerations:
- [ ] Encrypt sensitive data before Watch Connectivity transfer
- [ ] Use
transferUserInfofor guaranteed delivery of sensitive data - [ ] Don't store plaintext credentials on Watch
- [ ] Consider if Watch really needs sensitive data
watchOS Keychain
watchOS has its own Keychain:
// Keychain items are NOT automatically shared with iPhone
// Use Watch Connectivity to sync credentials if needed
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.watch",
kSecAttrAccount as String: "token",
kSecValueData as String: tokenData,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]Wrist Detection
Watch locks when removed from wrist:
import WatchKit
// Check if wrist detection is enabled
if WKInterfaceDevice.current().wristLocation == .left ||
WKInterfaceDevice.current().wristLocation == .right {
// Wrist detection is working
}
// For sensitive operations, verify device is on wrist
// Note: This is handled automatically by watchOS for Keychain accessCross-Platform Considerations
Keychain Accessibility Comparison
| Accessibility | iOS | macOS | watchOS |
|---|---|---|---|
WhenUnlocked | Unlocked | Login session | On wrist |
AfterFirstUnlock | After first unlock | After login | After unlock |
ThisDeviceOnly | Not in backup | Not synced | Watch only |
Shared Keychain Between Devices
iCloud Keychain syncs across devices:
// To prevent iCloud sync, use ThisDeviceOnly variants
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
// Or explicitly disable sync
kSecAttrSynchronizable as String: falsePlatform Detection for Security Features
var securityCapabilities: [String] {
var capabilities: [String] = []
#if os(iOS)
capabilities.append("Data Protection")
if SecureEnclave.isAvailable {
capabilities.append("Secure Enclave")
}
#endif
#if os(macOS)
capabilities.append("Hardened Runtime")
capabilities.append("Sandbox")
#endif
#if os(watchOS)
capabilities.append("Wrist Detection")
#endif
return capabilities
}Checklist by Platform
iOS
- [ ] Appropriate Data Protection class for files
- [ ] Keychain with
ThisDeviceOnlyfor sensitive data - [ ] Content blurred when app backgrounds
- [ ] Jailbreak detection for high-security apps
- [ ] Export compliance declared correctly
- [ ] App Groups used securely
macOS
- [ ] Sandbox enabled with minimal entitlements
- [ ] Hardened Runtime enabled
- [ ] No temporary exception entitlements (or migration plan)
- [ ] XPC services validated
- [ ] Helper tools code-signed and validated
- [ ] Notarization configured
watchOS
- [ ] HealthKit data handled with care
- [ ] Watch Connectivity data encrypted
- [ ] Minimal data stored on Watch
- [ ] Keychain used for credentials
- [ ] Sensitive operations respect wrist detection
Secure Storage
Patterns for securely storing sensitive data on Apple platforms.
Storage Decision Matrix
| Data Type | Recommended Storage | Data Protection Class |
|---|---|---|
| API tokens, passwords | Keychain | N/A (Keychain handles) |
| Encryption keys | Keychain + Secure Enclave | N/A |
| User preferences (non-sensitive) | UserDefaults | N/A |
| Sensitive files | Files + Data Protection | .complete |
| Cached sensitive data | Files + Data Protection | .completeUnlessOpen |
| Health/financial data | Keychain or encrypted files | .complete |
Keychain
Basic Keychain Operations
✅ Secure Pattern: KeychainManager
import Foundation
import Security
enum KeychainError: Error {
case duplicateItem
case itemNotFound
case unexpectedStatus(OSStatus)
case invalidData
}
final class KeychainManager {
static let shared = KeychainManager()
private let service: String
init(service: String = Bundle.main.bundleIdentifier ?? "com.app.keychain") {
self.service = service
}
// MARK: - Save
func save(_ data: Data, for account: String, accessibility: CFString = kSecAttrAccessibleWhenUnlockedThisDeviceOnly) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessible as String: accessibility
]
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecDuplicateItem {
try update(data, for: account)
} else if status != errSecSuccess {
throw KeychainError.unexpectedStatus(status)
}
}
func save(_ string: String, for account: String) throws {
guard let data = string.data(using: .utf8) else {
throw KeychainError.invalidData
}
try save(data, for: account)
}
// MARK: - Read
func read(account: String) throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
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 else {
if status == errSecItemNotFound {
throw KeychainError.itemNotFound
}
throw KeychainError.unexpectedStatus(status)
}
return data
}
func readString(account: String) throws -> String {
let data = try read(account: account)
guard let string = String(data: data, encoding: .utf8) else {
throw KeychainError.invalidData
}
return string
}
// MARK: - Update
private func update(_ data: Data, for account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let attributes: [String: Any] = [
kSecValueData as String: data
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
}
// MARK: - Delete
func delete(account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unexpectedStatus(status)
}
}
func deleteAll() throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unexpectedStatus(status)
}
}
}Keychain Accessibility Options
| Constant | When Accessible | Survives Backup |
|---|---|---|
kSecAttrAccessibleWhenUnlockedThisDeviceOnly | Device unlocked | No (recommended) |
kSecAttrAccessibleWhenUnlocked | Device unlocked | Yes |
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly | After first unlock | No |
kSecAttrAccessibleAfterFirstUnlock | After first unlock | Yes |
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly | Passcode set + unlocked | No |
✅ Recommended for Most Cases
kSecAttrAccessibleWhenUnlockedThisDeviceOnly- Only accessible when device is unlocked
- Not included in backups (device-specific)
- Good balance of security and usability
✅ For Background Operations
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly- Accessible after device unlocked once since boot
- Needed for background refresh, push notifications
- Still device-specific
❌ Anti-patterns
// NEVER store sensitive data in UserDefaults
UserDefaults.standard.set(password, forKey: "password")
UserDefaults.standard.set(apiToken, forKey: "token")
// NEVER hardcode secrets
let apiKey = "sk-ant-api03-xxxxx"
private let secret = "my-secret-key"
// NEVER store in plain text files
try password.write(to: credentialsURL, atomically: true, encoding: .utf8)
// NEVER use kSecAttrAccessibleAlways (deprecated and insecure)
kSecAttrAccessible as String: kSecAttrAccessibleAlwaysData Protection
iOS encrypts files using Data Protection classes. Set the appropriate class based on when data needs to be accessible.
Data Protection Classes
| Class | File Accessible | Use Case |
|---|---|---|
.complete | Only when unlocked | Most sensitive data |
.completeUnlessOpen | Can finish writes when locked | Active downloads |
.completeUntilFirstUserAuthentication | After first unlock | Background operations |
.none | Always | Non-sensitive cached data |
Setting Data Protection on Files
import Foundation
// When creating a file
func writeSecureFile(data: Data, to url: URL) throws {
try data.write(to: url, options: [.completeFileProtection])
}
// For existing files
func setFileProtection(for url: URL) throws {
try FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.complete],
ofItemAtPath: url.path
)
}
// Check current protection
func checkProtection(for url: URL) -> FileProtectionType? {
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path)
return attributes?[.protectionKey] as? FileProtectionType
}Data Protection in Info.plist
Set default protection for all app files:
<key>NSFileProtectionComplete</key>
<true/>Core Data with Data Protection
let container = NSPersistentContainer(name: "Model")
// Set protection on store file
let storeURL = container.persistentStoreDescriptions.first?.url
if let url = storeURL {
try? FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.complete],
ofItemAtPath: url.path
)
}Secure Enclave
The Secure Enclave is a hardware security module for cryptographic key operations. Keys never leave the Secure Enclave.
When to Use Secure Enclave
- Signing operations (authentication tokens)
- Key agreement (establishing shared secrets)
- Protecting high-value encryption keys
Creating Secure Enclave Keys
import Security
import CryptoKit
enum SecureEnclaveError: Error {
case notAvailable
case keyGenerationFailed(OSStatus)
case signingFailed
}
final class SecureEnclaveManager {
static let shared = SecureEnclaveManager()
private let tag = "com.app.secureenclave.signing"
var isAvailable: Bool {
SecureEnclave.isAvailable
}
// MARK: - Key Management
func createKey() throws -> SecKey {
guard isAvailable else {
throw SecureEnclaveError.notAvailable
}
// Delete existing key if present
deleteKey()
var error: Unmanaged<CFError>?
// Access control: require biometric or passcode
guard let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet],
&error
) else {
throw error!.takeRetainedValue()
}
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: tag.data(using: .utf8)!,
kSecAttrAccessControl as String: accessControl
]
]
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
throw error!.takeRetainedValue()
}
return privateKey
}
func getKey() -> SecKey? {
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationTag as String: tag.data(using: .utf8)!,
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecReturnRef as String: true
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
return nil
}
return (result as! SecKey)
}
func deleteKey() {
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationTag as String: tag.data(using: .utf8)!
]
SecItemDelete(query as CFDictionary)
}
// MARK: - Signing
func sign(data: Data, with key: SecKey) throws -> Data {
var error: Unmanaged<CFError>?
guard let signature = SecKeyCreateSignature(
key,
.ecdsaSignatureMessageX962SHA256,
data as CFData,
&error
) else {
throw error!.takeRetainedValue()
}
return signature as Data
}
// MARK: - Verification
func verify(signature: Data, for data: Data, with key: SecKey) -> Bool {
guard let publicKey = SecKeyCopyPublicKey(key) else {
return false
}
var error: Unmanaged<CFError>?
return SecKeyVerifySignature(
publicKey,
.ecdsaSignatureMessageX962SHA256,
data as CFData,
signature as CFData,
&error
)
}
}CryptoKit with Secure Enclave (Simpler API)
import CryptoKit
// Check availability
guard SecureEnclave.isAvailable else {
// Fall back to software key
return
}
// Create key with biometric protection
let key = try SecureEnclave.P256.Signing.PrivateKey(
accessControl: SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet],
nil
)!
)
// Sign data
let dataToSign = "Important message".data(using: .utf8)!
let signature = try key.signature(for: dataToSign)
// Verify
let isValid = key.publicKey.isValidSignature(signature, for: dataToSign)Clearing Sensitive Data
Clear Memory After Use
// For sensitive strings, overwrite memory
func clearSensitiveString(_ string: inout String) {
string = String(repeating: "\0", count: string.count)
string = ""
}
// For Data
func clearSensitiveData(_ data: inout Data) {
data.resetBytes(in: 0..<data.count)
data = Data()
}
// Use pattern
var password = "secret"
defer { clearSensitiveString(&password) }
// Use password...Clear on Logout
func logout() {
// Clear Keychain
try? KeychainManager.shared.deleteAll()
// Clear cached files
let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
try? FileManager.default.removeItem(at: cacheURL.appendingPathComponent("sensitive"))
// Clear in-memory state
currentUser = nil
authToken = nil
}Checklist
Keychain
- [ ] All credentials stored in Keychain, not UserDefaults
- [ ] Appropriate accessibility level set
- [ ] Using
ThisDeviceOnlyvariants when possible - [ ] Keychain items deleted on logout
- [ ] Error handling for all Keychain operations
Data Protection
- [ ] Sensitive files use
.completeprotection - [ ] Background-accessible files use
.completeUntilFirstUserAuthentication - [ ] Core Data stores have appropriate protection
Secure Enclave
- [ ] High-value keys stored in Secure Enclave
- [ ] Fallback for devices without Secure Enclave
- [ ] Biometric protection where appropriate
General
- [ ] No hardcoded secrets in source code
- [ ] No secrets in Info.plist
- [ ] Sensitive data cleared from memory when done
- [ ] No sensitive data in logs or crash reports