
Swift Security
- 1.7k installs
- 944 repo stars
- Updated July 15, 2026
- dpearson2699/swift-ios-skills
swift-security is an agent skill that use when working with ios/macos keychain services (secitem queries, ksecclass, osstatus errors), biometric authentication (lacontext, face id, touch id), cryptokit (aes-gcm, chachapo
About
swift-security is an agent skill from dpearson2699/swift-ios-skills that use when working with ios/macos keychain services (secitem queries, ksecclass, osstatus errors), biometric authentication (lacontext, face id, touch id), cryptokit (aes-gcm, chachapoly, ecdsa, ecdh, h. # Swift Security Use this skill for client-side Apple platform security work: Keychain Services, access control, biometric-gated secrets, CryptoKit, Secure Enclave keys, credential storage, certificate trust, keychain sharing, legacy secret migration, security testing, and OWASP mobile compliance mapping. Default to iOS 17+ and Swift concurrency Developers invoke swift-security during ship/testing work for testing & qa tasks. The skill documents triggers, prerequisites, and step-by-step workflows grounded in SKILL.md. Compatible with Claude Code, Cursor, and Codex agent runtimes that load marketplace skills. Review the Security Audits panel on this listing before installing in production environments.
- Use this skill for client-side Apple platform security work: Keychain Services,
- access control, biometric-gated secrets, CryptoKit, Secure Enclave keys,
- credential storage, certificate trust, keychain sharing, legacy secret
- migration, security testing, and OWASP mobile compliance mapping.
- Default to iOS 17+ and Swift concurrency examples when the deployment target is
Swift Security by the numbers
- 1,715 all-time installs (skills.sh)
- +125 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #422 of 2,159 Testing & QA skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
swift-security capabilities & compatibility
- Capabilities
- use this skill for client side apple platform se · access control, biometric gated secrets, cryptok · credential storage, certificate trust, keychain · migration, security testing, and owasp mobile co · default to ios 17+ and swift concurrency example
- Use cases
- orchestration
What swift-security says it does
Use this skill for client-side Apple platform security work: Keychain Services,
access control, biometric-gated secrets, CryptoKit, Secure Enclave keys,
credential storage, certificate trust, keychain sharing, legacy secret
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill swift-securityAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.7k |
|---|---|
| repo stars | ★ 944 |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 15, 2026 |
| Repository | dpearson2699/swift-ios-skills ↗ |
What it does
Use when working with iOS/macOS Keychain Services (SecItem queries, kSecClass, OSStatus errors), biometric authentication (LAContext, Face ID, Touch ID), CryptoKit (AES-GCM, ChaChaPoly, ECDSA, ECDH, H
Who is it for?
Developers working on testing & qa during ship tasks.
Skip if: Tasks outside Testing & QA scope described in SKILL.md.
When should I use this skill?
Use when working with iOS/macOS Keychain Services (SecItem queries, kSecClass, OSStatus errors), biometric authentication (LAContext, Face ID, Touch ID), CryptoKit (AES-GCM, ChaChaPoly, ECDSA, ECDH, H
What you get
Completed testing & qa workflow aligned with SKILL.md steps.
- severity-ranked findings
- corrected Keychain patterns
- security review notes
By the numbers
- Includes a keychain-token-review eval scenario for OAuth refresh token storage
Files
Swift Security
Use this skill for client-side Apple platform security work: Keychain Services, access control, biometric-gated secrets, CryptoKit, Secure Enclave keys, credential storage, certificate trust, keychain sharing, legacy secret migration, security testing, and OWASP mobile compliance mapping.
Default to iOS 17+ and Swift concurrency examples when the deployment target is unknown. Keep iOS 13+ compatibility notes when the user asks for older targets. Treat iOS 26 CryptoKit post-quantum APIs as availability-gated.
Contents
- Workflow
- Reference Loading
- Security Invariants
- Sibling Boundaries
- Review Checklist
- Common Mistakes
- Output Rules
- References
Workflow
Classify the request before loading references.
1. Review existing code: run the Review Checklist, then load common-anti-patterns.md plus the domain reference for each failing area. Report severity, evidence, and the corrected pattern. 2. Improve or migrate code: identify the migration type, load the migration and target-domain references, preserve existing data, verify the new item, then remove legacy storage only after success. 3. Implement new security code: load the minimum domain references, use the provided correct patterns, include OSStatus handling and tests, then run the relevant checklist.
Do not load every reference file by default. This skill is intentionally split for progressive disclosure; load only the files needed by the user's task.
Reference Loading
| If the task involves | Load |
|---|---|
| General keychain CRUD or OSStatus handling | keychain-fundamentals.md |
Choosing kSecClass or item identity | keychain-item-classes.md |
Accessibility classes or SecAccessControl | keychain-access-control.md |
| Face ID, Touch ID, or biometric-gated secrets | biometric-authentication.md |
| Secure Enclave keys | secure-enclave.md |
| Hashing, HMAC, AES-GCM, ChaChaPoly, HKDF, PBKDF2 | cryptokit-symmetric.md |
| Signing, ECDH, HPKE, ML-KEM, ML-DSA | cryptokit-public-key.md |
| OAuth tokens, API keys, logout, refresh rotation | credential-storage-patterns.md |
| App/extension keychain sharing | keychain-sharing.md |
| Certificate trust, SPKI pinning, mTLS | certificate-trust.md |
| UserDefaults/plist/NSCoding migration | migration-legacy-stores.md |
| Unit, integration, simulator, device, or CI tests | testing-security-code.md |
| OWASP MASVS/MASTG or enterprise audit mapping | compliance-owasp-mapping.md |
| Full security review | common-anti-patterns.md, then each touched domain reference |
Security Invariants
Use directive language only for these security invariants and the matching anti-patterns in common-anti-patterns.md. For architecture choices outside this list, use advisory language.
- Never store tokens, passwords, API keys, signing keys, or refresh tokens in
UserDefaults, Info.plist, .xcconfig, source code, logs, files, or NSCoding archives. Use Keychain or fetch secrets at runtime.
- Never ignore
OSStatus. EverySecItemAdd,SecItemCopyMatching,
SecItemUpdate, and SecItemDelete path must handle success and expected failures such as errSecDuplicateItem, errSecItemNotFound, and errSecInteractionNotAllowed.
- Never use
LAContext.evaluatePolicy()as the only gate for a secret. Bind
protected secrets to keychain items with SecAccessControl, then let keychain access trigger LocalAuthentication.
- Always set
kSecAttrAccessibleorkSecAttrAccessControlexplicitly when
adding keychain items.
- Always use add-or-update for persistent keychain writes. Do not delete-then-add
as a normal update path.
- Keep
SecItem*work off the main actor. Use an actor or serial queue for
keychain access.
- On macOS AppKit targets, target the data protection keychain with
kSecUseDataProtectionKeychain: true unless deliberately working with legacy file-based keychain items.
- Never reuse an AES-GCM nonce with the same key.
- Never use raw ECDH
SharedSecretbytes as a symmetric key. Derive with HKDF
or X9.63 derivation.
- Never use
Insecure.MD5orInsecure.SHA1for security purposes.
Sibling Boundaries
This skill owns client-side storage, cryptographic primitives, hardware-backed keys, and trust evaluation. Route adjacent work deliberately:
- Use
authenticationfor Sign in with Apple, passkeys, OAuth UI flows,
ASAuthorizationController, credential state, and account sign-in UX.
- Use
cryptokitfor primitive CryptoKit API syntax and examples when storage,
key lifecycle, protocol/trust design, Secure Enclave policy, certificate trust, misuse review, or compliance is not part of the task.
- Keep application-level E2E encryption security reviews here when the work
involves key ownership, derivation, storage, rotation/recovery, Secure Enclave, HPKE/PQC migration, protocol trust boundaries, or misuse analysis.
- Use
device-integrityfor DeviceCheck and App Attest attestation/assertion
flows.
- Use
ios-networkingfor URLSession, request pipelines, ATS configuration,
retries, caching, reachability, and transport architecture.
- Use
app-store-reviewfor privacy manifests, ATT, App Review guideline
compliance, and submission readiness.
This skill may mention those areas only to identify a security handoff.
Review Checklist
Use this checklist for code reviews and migration plans. Mark each item pass, fail, or not applicable; for each failure, cite the reference file and severity.
- Secrets are not stored in
UserDefaults, plists, source, logs, files, or
archives.
- Every
SecItem*call checksOSStatusand handles common recoverable errors. - Biometric access to secrets is keychain-bound with
SecAccessControl, not a
standalone Bool from LAContext.evaluatePolicy().
- Keychain add dictionaries set an explicit accessibility policy.
- Keychain writes use add-or-update rather than delete-then-add.
- Keychain work is isolated from UI/main-actor code.
- The selected
kSecClassmatches the item type and primary-key attributes. - CryptoKit code avoids nonce reuse, raw shared-secret use, weak hashes, and
hardcoded keys.
- Custom encryption designs identify key ownership, derivation, storage,
rotation/recovery, availability gates, and protocol/trust boundaries.
- Secure Enclave code checks availability, handles simulator/device differences,
persists only dataRepresentation, and designs for device-bound keys.
- App/extension sharing uses full Team ID access groups and matching
entitlements on every target.
- Certificate trust uses current
SecTrustAPIs, validates hostname/policy, and
uses SPKI or CA pinning when pinning is required.
- macOS keychain code intentionally chooses data protection or file-based
keychain behavior.
- Tests cover success, duplicate, missing item, locked-device, simulator/device,
and migration paths where applicable.
- OWASP MASVS/MASTG mappings are included when compliance is requested.
Common Mistakes
- Generating partial keychain examples without duplicate handling or
errSecItemNotFound handling.
- Adding biometric UI but leaving the secret readable without keychain access
control.
- Choosing
kSecAttrAccessibleWhenUnlockedimplicitly by omitting the attribute. - Using
kSecAttrAccessibleAlwaysor
kSecAttrAccessibleAlwaysThisDeviceOnly, both deprecated.
- Mixing
kSecAttrAccessibleandkSecAttrAccessControlon the same add query. - Treating Secure Enclave keys as importable, exportable, syncable, or suitable
for symmetric encryption.
- Claiming SHA-3, ML-KEM, ML-DSA, or X-Wing CryptoKit APIs are available before
iOS 26.
- Treating HPKE as available before iOS 17.
- Implementing certificate pinning by hashing only raw key bytes instead of the
correct SPKI representation.
- Expanding this skill into account-login, networking, App Attest, or App Store
review guidance instead of handing off to sibling skills.
Output Rules
- For security findings, state severity: CRITICAL for exploitable secret or
cryptography failures, HIGH for silent security boundary/data-loss issues, and MEDIUM for brittle or incomplete hardening.
- Include wrong and corrected code examples for implementation reviews when a
concrete anti-pattern is present.
- Include minimum iOS/macOS availability when recommending versioned APIs.
- Cite the reference file that supports each substantive security pattern.
- For keychain code, include
OSStatushandling and explicit accessibility in
examples.
- For implementation or migration answers, end with
## Reference Filesand
list the loaded references with a one-line purpose.
- Do not invent WWDC session numbers or source citations. If a claim is not
present in the loaded references or official Apple documentation, say it needs verification.
References
- keychain-fundamentals.md - SecItem CRUD, OSStatus handling, add-or-update, macOS data protection keychain.
- keychain-item-classes.md -
kSecClassselection, primary keys, certificates, identities. - keychain-access-control.md - Accessibility constants,
SecAccessControl, background access, data protection. - biometric-authentication.md - Keychain-bound biometrics,
LAContext, enrollment-change handling. - secure-enclave.md - Secure Enclave constraints, persistence, biometric keys, iOS 26 PQ APIs.
- cryptokit-symmetric.md - SHA, HMAC, AES-GCM, ChaChaPoly, HKDF, PBKDF2.
- cryptokit-public-key.md - Signing, key agreement, HPKE, ML-KEM, ML-DSA, key formats.
- credential-storage-patterns.md - OAuth tokens, API keys, rotation, logout cleanup.
- keychain-sharing.md - Access groups, extensions, iCloud sync, macOS access groups.
- certificate-trust.md - SecTrust, SPKI/CA pinning,
NSPinnedDomains, client certificates. - migration-legacy-stores.md - UserDefaults/plist/NSCoding migration and cleanup.
- common-anti-patterns.md - Review backbone for insecure generated code.
- testing-security-code.md - Protocol mocks, real keychain tests, CI/device split.
- compliance-owasp-mapping.md - OWASP Mobile Top 10, MASVS, MASTG evidence mapping.
{
"skill_name": "swift-security",
"evals": [
{
"id": 0,
"name": "keychain-token-review",
"prompt": "Review this iOS token storage helper before we ship it. It saves an OAuth refresh token with `UserDefaults.standard.set(refreshToken, forKey: \"refresh\")`, writes access tokens with `SecItemAdd(query as CFDictionary, nil)` but ignores the returned OSStatus, omits `kSecAttrAccessible`, and calls `SecItemDelete` before every save to avoid duplicates. What is wrong, what severity would you assign, and what should the corrected Keychain pattern require?",
"expected_output": "A security review that flags plaintext token storage, ignored OSStatus, missing accessibility, and delete-then-add as findings; assigns severity; and requires Keychain add-or-update with explicit accessibility and recoverable error handling.",
"files": [],
"assertions": [
"Flags the refresh token in UserDefaults as a critical or high-severity insecure secret storage finding.",
"Requires checking OSStatus for SecItem calls and specifically handling errSecDuplicateItem, errSecItemNotFound, and errSecInteractionNotAllowed where relevant.",
"Requires an explicit kSecAttrAccessible or SecAccessControl policy for added items.",
"Rejects delete-then-add as the normal update strategy and recommends add-or-update with SecItemUpdate on errSecDuplicateItem.",
"Cites or names the relevant swift-security reference files for Keychain fundamentals, credential storage, access control, or common anti-patterns."
]
},
{
"id": 1,
"name": "biometric-secret-boundary",
"prompt": "A banking app wants Face ID before showing an account number. The current plan is to call `LAContext.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: ...)`, check the returned Bool, then read the account number from UserDefaults. They also forgot `NSFaceIDUsageDescription`. Write the correction plan and include the important iOS API constraints.",
"expected_output": "A correction plan that rejects the standalone LAContext Bool gate, moves the secret into a Keychain item protected by SecAccessControl, mentions Face ID usage description, handles enrollment changes, and states availability constraints for the biometric APIs.",
"files": [],
"assertions": [
"States that LAContext.evaluatePolicy alone must not release or protect the stored secret.",
"Requires storing the account number or token in Keychain behind SecAccessControl with an appropriate flag such as .biometryCurrentSet or .userPresence.",
"Mentions NSFaceIDUsageDescription as required for Face ID use.",
"Explains the enrollment-change effect of .biometryCurrentSet or evaluatedPolicyDomainState and the need for recovery/re-enrollment handling.",
"Cites or names biometric-authentication and keychain-access-control references."
]
},
{
"id": 2,
"name": "security-sibling-boundary",
"prompt": "A team asks for one security checklist covering Sign in with Apple, passkey server verification, App Attest assertions, URLSession quantum-secure TLS, custom end-to-end document encryption on iOS 26, Keychain refresh-token storage, Secure Enclave keys, and App Store privacy manifests. Give a concise scope review for what swift-security should answer directly and what should be handed to sibling skills.",
"expected_output": "A boundary-aware scope review that keeps Keychain, CryptoKit, Secure Enclave, credential storage, and certificate trust in swift-security; routes auth UI/server passkeys, App Attest, URLSession/TLS transport, and App Store privacy manifests to sibling skills; and names iOS 26 CryptoKit availability for custom quantum-secure workflows.",
"files": [],
"assertions": [
"Keeps Keychain refresh-token storage, Secure Enclave key policy, certificate trust, and client-side CryptoKit guidance in swift-security scope.",
"Routes Sign in with Apple, passkey registration/assertion UI, OAuth account flows, and passkey relying-party verification to authentication rather than expanding swift-security.",
"Routes App Attest and DeviceCheck assertions to device-integrity.",
"Routes URLSession transport architecture or TLS configuration to ios-networking while noting swift-security can discuss custom CryptoKit end-to-end encryption.",
"Routes App Store privacy manifests and submission compliance to app-store-review.",
"Mentions iOS 26 availability for ML-KEM, ML-DSA, X-Wing, SHA-3, or SecureEnclave post-quantum APIs when discussing custom document encryption."
]
}
]
}
Biometric Authentication
Domain scope: SecAccessControl + LAContext integration, the LAContext-only bypass vulnerability, hardware-bound biometric gating, fallback behavior, UI customization, enrollment change detection, thread safety.
>
Risk level: CRITICAL — #1 most dangerous AI-generated pattern. LAContext.evaluatePolicy() used alone is trivially bypassable at runtime.---
Contents
- The Boolean Gate Vulnerability
- The Dangerous Pattern — Boolean Gate
- How Attackers Bypass It
- The Secure Pattern — Hardware-Bound Secrets
- Step 1 — Create the Access Control Object
- Step 2 — Store a Secret Bound to Biometric Auth
- Step 3 — Retrieve the Secret (Biometric Prompt Appears Automatically)
- `evaluatePolicy` vs `evaluateAccessControl`
- Biometric Flag Selection
- `.biometryCurrentSet` — Banking, Payments, Credential Storage
- `.biometryAny` — Convenience Features, Moderate Sensitivity
- `.userPresence` — Maximum Device Compatibility
- Combining Flags
- Biometric Availability Checks and Graceful Degradation
- Incomplete Availability Check
- Complete Availability Evaluation
- Graceful Degradation Flow
- Enrollment Change Detection
- Thread Safety and async/await
- Actor-Isolated Biometric Keychain (iOS 15+)
- SwiftUI ViewModel Integration
- Secure Enclave-Backed Keys with Biometric Protection
- SDLC Controls — Catching the Anti-Pattern in CI
- Dynamic Verification — Proving Bypass Resistance
- Key References
- Cross-References
- Summary Checklist
The Boolean Gate Vulnerability
The most dangerous pattern AI coding assistants generate for iOS biometric authentication is LAContext.evaluatePolicy() used as a standalone authentication gate. This pattern appears in virtually every tutorial, Stack Overflow answer, and AI training corpus — and it is trivially bypassable.
The attack requires no exploit. An attacker uses Frida or objection to hook the Objective-C callback and force success = true, bypassing Face ID or Touch ID entirely. The formal weakness classification is CWE-288: Authentication Bypass Using an Alternate Path or Channel.
OWASP MASTG explicitly fails any app relying solely on evaluatePolicy (test MASTG-TEST-0266, requirements MSTG-AUTH-8 and MSTG-AUTH-12). The standard states: biometric authentication must not be event-bound (returning true/false); it must be based on unlocking the keychain/keystore.
The Dangerous Pattern — Boolean Gate
// ❌ DANGEROUS: Trivially bypassable with Frida — do NOT use for security
import LocalAuthentication
func authenticateUser() {
let context = LAContext()
var error: NSError?
if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Authenticate to access your account"
) { success, authError in
DispatchQueue.main.async {
if success {
self.isAuthenticated = true // ← Just a boolean in hookable memory
self.showProtectedContent() // ← No secret unlocked, no key released
}
}
}
}
}Why this fails: evaluatePolicy() asks the OS "did the user authenticate?" and receives a boolean answer in user-space. No cryptographic material is involved. No secret is decrypted. The entire security model rests on a boolean that exists in hookable memory.
How Attackers Bypass It
The objection tool (built on Frida) provides a one-command bypass:
objection -g "com.example.targetapp" explore
ios ui biometrics_bypassThe hook listens for invocations of -[LAContext evaluatePolicy:localizedReason:reply:], intercepts the reply block, and replaces the success boolean with true. The equivalent raw Frida script:
// Frida script — forces evaluatePolicy success = true
if (ObjC.available) {
var hook = ObjC.classes.LAContext["- evaluatePolicy:localizedReason:reply:"];
Interceptor.attach(hook.implementation, {
onEnter: function (args) {
var block = new ObjC.Block(args[4]);
const callback = block.implementation;
block.implementation = function (error, value) {
const result = callback(1, null); // 1 = true, null = no error
return result;
};
},
});
}The objection wiki confirms the attack boundary: this bypass does not work against keychain items protected with access control flags like .biometryCurrentSet or .biometryAny. That boundary is the entire basis of the secure pattern.
✅ Correct pattern in this threat model: use biometrics only to unlock keychain-protected secrets (SecAccessControl + SecItemCopyMatching), never as a standalone boolean gate.
---
The Secure Pattern — Hardware-Bound Secrets
The correct architecture stores a secret in the iOS keychain with biometric access control. The secret's encryption key is held by the Secure Enclave — a dedicated processor running its own microkernel (sepOS), with its own encrypted memory, completely isolated from the application processor.
When the app requests the secret, the Secure Enclave independently verifies the biometric match and only then releases the decryption key. There is no boolean to hook. The data physically cannot be read without valid biometric authentication.
WWDC 2014 Session 711 ("Keychain and Authentication with Touch ID") drew the critical distinction:
- `evaluatePolicy`: "Trust the OS" — vulnerable if runtime is compromised
- Keychain + SecAccessControl: "Trust the Secure Enclave" — ACLs evaluated inside hardware
Step 1 — Create the Access Control Object
import LocalAuthentication
import Security
enum BiometricKeychainError: Error {
case accessControlCreationFailed
case keychainOperationFailed(status: OSStatus)
case dataConversionFailed
case biometryNotAvailable(reason: String)
}
func createBiometricAccessControl() throws -> SecAccessControl {
var error: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, // Strongest: requires passcode, device-only
.biometryCurrentSet, // Invalidates on enrollment change
&error
) else {
throw BiometricKeychainError.accessControlCreationFailed
}
return accessControl
}Step 2 — Store a Secret Bound to Biometric Auth
// ✅ SECURE: Secret is encrypted by Secure Enclave, released only on biometric match
func storeSecretWithBiometric(secret: Data, account: String, service: String) throws {
let accessControl = try createBiometricAccessControl()
let baseQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: service,
kSecAttrSynchronizable as String: kCFBooleanFalse // Never sync biometric-gated secrets
]
var addQuery = baseQuery
addQuery[kSecValueData as String] = secret
addQuery[kSecAttrAccessControl as String] = accessControl
// NOTE: Do NOT set kSecAttrAccessible — it conflicts with kSecAttrAccessControl
let status = SecItemAdd(addQuery as CFDictionary, nil)
switch status {
case errSecSuccess:
return
case errSecDuplicateItem:
let updateAttrs: [String: Any] = [kSecValueData as String: secret]
let updateStatus = SecItemUpdate(baseQuery as CFDictionary, updateAttrs as CFDictionary)
guard updateStatus == errSecSuccess else {
throw BiometricKeychainError.keychainOperationFailed(status: updateStatus)
}
default:
throw BiometricKeychainError.keychainOperationFailed(status: status)
}
}Critical detail: Do NOT set both kSecAttrAccessible and kSecAttrAccessControl in the same query. They conflict — SecAccessControl already encodes the accessibility level. Setting both causes errSecParam.
Critical detail: Always use ThisDeviceOnly accessibility for biometric-gated secrets. The ThisDeviceOnly suffix ensures the secret is hardware-bound and excluded from iCloud backups. Syncing biometric-gated secrets across devices expands the attack surface.
Migration note: Updating the secret data preserves the existing access control. If you intentionally need to change the SecAccessControl policy, use an explicit migration path from keychain-access-control.md; do not hide delete/re-add inside the normal save path.
Step 3 — Retrieve the Secret (Biometric Prompt Appears Automatically)
// ✅ SECURE: System presents biometric prompt; Secure Enclave gates decryption
func retrieveSecretWithBiometric(account: String, service: String) throws -> Data {
let context = LAContext()
context.localizedReason = "Authenticate to access your credentials"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: service,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecUseAuthenticationContext as String: context
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
guard let data = result as? Data else {
throw BiometricKeychainError.dataConversionFailed
}
return data // Secret returned ONLY after Secure Enclave validates biometric
case errSecItemNotFound:
throw BiometricKeychainError.keychainOperationFailed(status: status)
case errSecUserCanceled:
throw BiometricKeychainError.keychainOperationFailed(status: status)
case errSecAuthFailed:
throw BiometricKeychainError.keychainOperationFailed(status: status)
default:
throw BiometricKeychainError.keychainOperationFailed(status: status)
}
}Key insight: Authentication and data protection are the same operation, not sequential ones. When SecItemCopyMatching encounters an item with biometric access control, the system presents the biometric prompt automatically. The Secure Enclave verifies the match internally and only then unwraps the AES-256-GCM decryption key. There is no callback to intercept.
---
evaluatePolicy vs evaluateAccessControl
These two LAContext methods represent the two trust models from WWDC 2014 Session 711:
`evaluatePolicy(_:localizedReason:reply:)` triggers biometric authentication and returns a boolean. The Secure Enclave validates the biometric correctly, but the result is communicated to user-space as true/false. No key is released. The app branches on a boolean in hookable memory. This is "trust the OS."
`evaluateAccessControl(_:operation:localizedReason:reply:)` evaluates a SecAccessControl object for a specific cryptographic operation (.useItem, .useKeySign, .useKeyDecrypt). When used with keychain items, the authenticated LAContext is passed to SecItemCopyMatching via kSecUseAuthenticationContext, and the Secure Enclave recognizes the prior authentication. This is "trust the Secure Enclave."
In practice, you rarely call `evaluateAccessControl` directly. The recommended flow: store data with SecAccessControl via SecItemAdd, then retrieve with SecItemCopyMatching. The system handles the biometric prompt automatically when the query encounters an ACL-protected item.
The only legitimate use of evaluatePolicy is non-security-critical UI gating — deciding whether to show a "Sign in with Face ID" button. It must never protect sensitive data or gate access to secrets.
---
Biometric Flag Selection
SecAccessControlCreateFlags provides three biometric-related flags. Choosing the wrong one is a common mistake even in otherwise-correct implementations.
.biometryCurrentSet — Banking, Payments, Credential Storage
Ties the keychain item to the exact biometric enrollment at time of storage. If the user adds a fingerprint, re-enrolls Face ID, or removes a biometric entry, the item becomes permanently inaccessible.
// ✅ Strongest biometric binding — invalidates on enrollment change
let access = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet, nil
)Tradeoff: Users who change biometrics must re-authenticate via your app's password flow. Detect enrollment changes via LAContext.evaluatedPolicyDomainState (see Enrollment Change Detection below) and present graceful re-enrollment.
.biometryAny — Convenience Features, Moderate Sensitivity
Survives biometric enrollment changes. An attacker who enrolls their own biometrics on a compromised device can access the data.
// Survives re-enrollment — better UX, weaker security
let access = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryAny, nil
)Use case: "Remember me" features, non-critical app locks, preferences that benefit from biometric convenience without protecting financial data.
.userPresence — Maximum Device Compatibility
Allows passcode fallback when biometrics are unavailable. Weaker because passcodes are susceptible to shoulder-surfing.
// Broadest compatibility — biometric or passcode
let access = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.userPresence, nil
)Use case: Accessibility-first apps, devices without biometric hardware, or as a .biometryCurrentSet degradation path.
Combining Flags
Flags can be combined with .or and .and conjunctions:
// ✅ Strong biometric binding WITH passcode escape hatch
let access = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.biometryCurrentSet, .or, .devicePasscode], nil
)This combination is practical for most production apps — strong biometric security with a recovery path when biometrics become unavailable.
---
Biometric Availability Checks and Graceful Degradation
Incomplete Availability Check
// ❌ WRONG: Ignores WHY biometrics failed — user gets no guidance
func checkBiometrics() -> Bool {
let context = LAContext()
var error: NSError?
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
}Complete Availability Evaluation
// ✅ CORRECT: Evaluates every failure reason with actionable guidance
enum BiometricAvailability {
case available(type: LABiometryType)
case notEnrolled // Hardware exists, no biometrics registered
case lockedOut // Too many failed attempts — passcode required
case notAvailable // No hardware or restricted by MDM
case passcodeNotSet // No device passcode — biometrics require one
}
func evaluateBiometricAvailability() -> BiometricAvailability {
let context = LAContext()
var error: NSError?
if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
return .available(type: context.biometryType)
}
guard let laError = error as? LAError else { return .notAvailable }
switch laError.code {
case .biometryNotEnrolled:
return .notEnrolled // → "Enable Face ID in Settings"
case .biometryLockout:
return .lockedOut // → Prompt passcode to reset sensor
case .biometryNotAvailable:
return .notAvailable // → Hide biometric UI entirely
case .passcodeNotSet:
return .passcodeNotSet // → "Set a passcode to use Face ID"
default:
return .notAvailable
}
}Graceful Degradation Flow
// ✅ Degrades from biometric → passcode → password login
func authenticateWithGracefulDegradation() async throws -> Data {
let availability = evaluateBiometricAvailability()
switch availability {
case .available:
return try retrieveSecretWithBiometric(account: "user", service: "com.app.auth")
case .lockedOut:
// Biometrics locked — use .userPresence item for passcode fallback
return try retrieveSecretWithPasscodeFallback(account: "user", service: "com.app.auth")
case .notEnrolled:
throw BiometricKeychainError.biometryNotAvailable(
reason: "Please enable Face ID in Settings > Face ID & Passcode"
)
case .notAvailable, .passcodeNotSet:
throw BiometricKeychainError.biometryNotAvailable(
reason: "Biometric authentication is not available on this device"
)
}
}Critical: Failing to handle .biometryLockout strands users. The app cannot bypass this lockout — the user must successfully enter their device passcode to re-enable the biometric sensor. If your app has no fallback, users are permanently locked out until they leave your app and unlock with passcode.
Important: canEvaluatePolicy() is strictly for pre-flight UI decisions (showing or hiding a "Sign in with Face ID" button). It must never be used as a security control.
---
Enrollment Change Detection
When using .biometryCurrentSet, detect enrollment changes proactively so your app can guide the user through re-enrollment rather than presenting a cryptic keychain error.
// ✅ Detect biometric enrollment changes via domainState
class BiometricEnrollmentMonitor {
private let domainStateKey = "com.app.biometric.domainState"
/// Call after successful biometric setup to snapshot current enrollment
func saveCurrentEnrollment() {
let context = LAContext()
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) else { return }
// domainState changes whenever biometric enrollment changes
if let domainState = context.evaluatedPolicyDomainState {
UserDefaults.standard.set(domainState, forKey: domainStateKey)
}
}
/// Call on app launch or before biometric retrieval
func hasEnrollmentChanged() -> Bool {
let context = LAContext()
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) else {
return true // Can't evaluate — treat as changed
}
guard let currentState = context.evaluatedPolicyDomainState,
let savedState = UserDefaults.standard.data(forKey: domainStateKey) else {
return true // No saved state — first run or data cleared
}
return currentState != savedState
}
}Note: evaluatedPolicyDomainState is an opaque Data blob. It changes whenever biometric enrollment changes but reveals no information about the biometrics themselves. Store it in UserDefaults (not keychain) since it is not sensitive — it's only used for change detection.
---
Thread Safety and async/await
SecItemCopyMatching with biometric access control blocks the calling thread until the user completes authentication. Never run it on @MainActor or the main thread.
LAContext.evaluatePolicy's legacy completion handler executes on a private queue in an unspecified threading context. Direct UI updates from this callback cause crashes, especially on iOS 18 where threading strictness increased.
Actor-Isolated Biometric Keychain (iOS 15+)
@available(iOS 15.0, *)
actor BiometricKeychain {
func retrieveSecret(account: String, service: String) async throws -> Data {
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let context = LAContext()
context.localizedReason = "Authenticate to access your account"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: service,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecUseAuthenticationContext as String: context
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
if let data = result as? Data {
continuation.resume(returning: data)
} else {
continuation.resume(throwing: BiometricKeychainError.dataConversionFailed)
}
case errSecUserCanceled, errSecAuthFailed:
continuation.resume(throwing: BiometricKeychainError.keychainOperationFailed(status: status))
default:
continuation.resume(throwing: BiometricKeychainError.keychainOperationFailed(status: status))
}
}
}
}
}SwiftUI ViewModel Integration
@MainActor
class AuthViewModel: ObservableObject {
@Published var isAuthenticated = false
@Published var errorMessage: String?
private let keychain = BiometricKeychain()
func authenticate() {
Task {
do {
let secret = try await keychain.retrieveSecret(
account: "user_token",
service: "com.myapp.auth"
)
self.isAuthenticated = true
self.processToken(secret)
} catch {
self.errorMessage = error.localizedDescription
}
}
}
}Note on native async: LAContext gained evaluatePolicy(_:localizedReason:) async throws -> Bool in iOS 15. However, this is only relevant for the non-security-critical UI gating use case. For the secure keychain pattern, you wrap SecItemCopyMatching as shown above — there is no native async overload for SecItem\* APIs.
---
Secure Enclave-Backed Keys with Biometric Protection
For asymmetric key operations (signing, key agreement), combine Secure Enclave key generation with biometric access control via CryptoKit. The private key never leaves the Secure Enclave — all operations happen in hardware.
// ✅ Secure Enclave P-256 key with biometric protection (WWDC 2019-709)
let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet],
nil
)!
let privateKey = try SecureEnclave.P256.Signing.PrivateKey(
accessControl: accessControl
)
// Signing triggers biometric prompt automatically
let signature = try privateKey.signature(for: dataToSign)Frida operates in user-space on the application processor and has zero access to the Secure Enclave's internal state. The Secure Enclave's memory is encrypted with its own AES engine. Even a kernel-level compromise cannot extract the keys.
---
SDLC Controls — Catching the Anti-Pattern in CI
Because AI coding assistants frequently generate the vulnerable evaluatePolicy pattern, teams should implement automated detection:
Conceptual SAST rule (`INSECURE_BIOMETRIC_GATE`): Identify all calls to LAContext.evaluatePolicy. If the success boolean directly gates access to a resource AND there is no corresponding SecItemCopyMatching using a SecAccessControl object in the same flow, flag the code.
Security review sign-off criteria:
1. Zero instances of standalone LAContext.evaluatePolicy gating sensitive operations 2. Evidence of SecItemAdd with kSecAttrAccessControl using ThisDeviceOnly accessibility 3. Documented proof that objection bypass (ios ui biometrics_bypass) fails to unlock protected data 4. All LAError cases handled with graceful degradation 5. If .biometryCurrentSet is used, a tested re-enrollment recovery flow exists
---
Dynamic Verification — Proving Bypass Resistance
Static code review alone is insufficient. Verification requires dynamic testing:
Test procedure: On a jailbroken or instrumented device, inject a Frida script to hook -[LAContext evaluatePolicy:localizedReason:reply:] and force success = true.
Pass criteria: The app prevents access to protected data despite the manipulated callback. The secret remains locked because SecAccessControl + Secure Enclave enforcement is independent of the boolean.
Fail criteria: The app grants access after the hook forces success. This proves reliance on the vulnerable boolean gate.
---
Key References
- WWDC 2014 Session 711 — "Keychain and Authentication with Touch ID": Introduced the two trust models (evaluatePolicy vs keychain+ACL)
- WWDC 2019 Session 709 — "Cryptography and Your Apps": CryptoKit + Secure Enclave key generation with access control
- Apple Platform Security Guide — Secure Enclave architecture, keychain encryption chain (metadata key + secret key), ACL evaluation in hardware
- OWASP MASTG MSTG-AUTH-8 — Biometric authentication must not be event-bound
- OWASP MASTG MSTG-AUTH-12 — Integrity of biometric mechanism must be verified
- OWASP MASTG MASTG-TEST-0266 — Test for local authentication bypass
- objection wiki — "Understanding the iOS Biometrics Bypass": Confirms attack boundary at SecAccessControl
- TN3137 — "On Mac Keychain APIs and implementations" (macOS keychain unification)
---
Cross-References
keychain-fundamentals.md— SecItem CRUD patterns used by the keychain-bound biometric flowkeychain-access-control.md—SecAccessControlCreateWithFlags, accessibility constants, and flag composition rulessecure-enclave.md— Hardware-backed keys with biometric gating viaSecAccessControlcommon-anti-patterns.md— Anti-pattern #3 (LAContext-only biometric gate)credential-storage-patterns.md— Biometric protection for high-value credentials (OAuth tokens, API keys)testing-security-code.md— Protocol-based mocking for biometric flows, LAContext test strategiescompliance-owasp-mapping.md— M3 (Insecure Authentication/Authorization) biometric requirements
---
Summary Checklist
1. No standalone boolean gates — LAContext.evaluatePolicy() is NEVER the sole authentication mechanism for sensitive data; secrets are always bound to keychain + SecAccessControl 2. Hardware-gated secrets — All sensitive data protected by biometrics uses SecAccessControlCreateWithFlags with the Secure Enclave enforcing the ACL 3. Correct flag selection — .biometryCurrentSet for high-security (banking, payments); .biometryAny for convenience; .userPresence for broad compatibility or fallback 4. No kSecAttrAccessible conflict — kSecAttrAccessible and kSecAttrAccessControl are never set on the same keychain item 5. ThisDeviceOnly accessibility — Biometric-gated secrets use kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly or WhenUnlockedThisDeviceOnly; never syncable 6. Complete error handling — All LAError codes handled: .biometryNotEnrolled, .biometryLockout, .biometryNotAvailable, .passcodeNotSet, .userCancel, .userFallback 7. Graceful degradation — App provides fallback path (passcode or password) when biometrics are unavailable or locked out 8. Enrollment change detection — evaluatedPolicyDomainState monitored when using .biometryCurrentSet; re-enrollment flow implemented 9. Thread safety — SecItemCopyMatching with biometric ACL never runs on @MainActor; actor-isolated or dispatched to background queue 10. Dynamic verification — objection/Frida bypass test confirms protected data remains inaccessible when evaluatePolicy callback is hooked 11. SAST/linting — CI pipeline includes rule to flag standalone evaluatePolicy without corresponding SecAccessControl keychain operations
Certificate Trust Evaluation & Pinning
Scope: SecCertificate, SecTrust evaluation, SecIdentity, certificate pinning strategies (leaf / intermediate CA / SPKI hash / NSPinnedDomains), custom trust policies, client certificate authentication (mTLS), ATS interaction, and operational pin management. iOS 12+ / macOS 10.14+ baseline, with current-platform notes where relevant.
>
Out of scope: Network-layer encryption beyond TLS certificate handling, server-side certificate management, App Transport Security as a standalone topic (covered briefly where it intersects pinning).
---
Contents
- Core Security Types
- Trust Evaluation APIs
- SecTrustEvaluateAsyncWithError — recommended async API (iOS 13+)
- SecTrustEvaluateWithError — synchronous, still current (iOS 12+)
- SecTrustEvaluate — deprecated since iOS 13
- SecTrustResultType reference
- Custom Trust Policy Configuration
- Four Pinning Strategies
- Leaf certificate pinning — breaks on every renewal
- Intermediate CA pinning — 5–10 year validity window
- SPKI hash pinning — survives renewal with same key pair
- NSPinnedDomains — declarative pinning, zero code (iOS 14+)
- Pinning Strategy Decision Matrix
- SecCertificate and SecIdentity
- Creating certificates from DER data
- Importing PKCS#12 for client certificate authentication
- Client certificate authentication in URLSession
- Certificate chain inspection (backward-compatible)
- Anti-Patterns AI Code Generators Produce
- Backup Pins, Rotation, and Graceful Degradation
- ATS Interaction Points
- API Deprecation Timeline
- Thread Safety and Performance
- CI/CD Guardrails
- Cross-References
- WWDC and Reference Citations
- Summary Checklist
Core Security Types
| Type | Purpose | Key Operations |
|---|---|---|
SecCertificate | X.509 certificate (DER-encoded) | SecCertificateCreateWithData, SecCertificateCopyKey (iOS 12+), SecCertificateCopyData, SecCertificateCopySubjectSummary |
SecTrust | Trust evaluation context for a certificate chain against policies | SecTrustCreateWithCertificates, SecTrustEvaluateWithError (iOS 12+), SecTrustEvaluateAsyncWithError (iOS 13+) |
SecIdentity | Private key + certificate pair for client authentication | Extracted via SecPKCS12Import; used with URLCredential(identity:certificates:persistence:) |
SecPolicy | Validation policy (SSL hostname check, revocation) | SecPolicyCreateSSL, SecPolicyCreateRevocation |
---
Trust Evaluation APIs
Three trust evaluation functions exist. Only two are current.
SecTrustEvaluateAsyncWithError — recommended async API (iOS 13+)
func SecTrustEvaluateAsyncWithError(
_ trust: SecTrust,
_ queue: dispatch_queue_t,
_ result: @escaping (SecTrust, Bool, CFError?) -> Void
) -> OSStatusThe callback receives a Boolean result and optional error. The callback may fire synchronously if the trust object has a cached result. Always dispatch on a background queue — evaluation may perform network access for intermediate certificate fetching or revocation checks.
// ✅ CORRECT: Async trust evaluation with proper error handling
func evaluateTrust(_ trust: SecTrust, completion: @escaping (Bool, Error?) -> Void) {
let queue = DispatchQueue.global(qos: .userInitiated)
queue.async {
let status = SecTrustEvaluateAsyncWithError(trust, queue) { _, result, error in
completion(result, error as Error?)
}
if status != errSecSuccess {
completion(false, NSError(domain: NSOSStatusErrorDomain, code: Int(status)))
}
}
}Trust evaluation remains exposed through synchronous or callback-based Security APIs in current SDKs. Wrap manually when you need Swift concurrency:
// ✅ CORRECT: Swift concurrency wrapper
func evaluateTrust(_ trust: SecTrust) async throws -> Bool {
try await withCheckedThrowingContinuation { continuation in
let queue = DispatchQueue.global(qos: .userInitiated)
queue.async {
let status = SecTrustEvaluateAsyncWithError(trust, queue) { _, result, error in
if result {
continuation.resume(returning: true)
} else {
continuation.resume(throwing: error! as Error)
}
}
if status != errSecSuccess {
continuation.resume(throwing: NSError(
domain: NSOSStatusErrorDomain, code: Int(status)))
}
}
}
}SecTrustEvaluateWithError — synchronous, still current (iOS 12+)
func SecTrustEvaluateWithError(_ trust: SecTrust, _ error: UnsafeMutablePointer<CFError?>?) -> BoolNot deprecated. Valid inside URLSessionDelegate callbacks (already off main thread). Apple's warning: do not call from the main run loop — it may require network access.
SecTrustEvaluate — deprecated since iOS 13
// ❌ DEPRECATED: Returns opaque SecTrustResultType without error context
func SecTrustEvaluate(_ trust: SecTrust,
_ result: UnsafeMutablePointer<SecTrustResultType>) -> OSStatusReturns a SecTrustResultType enum requiring manual interpretation. Replaced by SecTrustEvaluateWithError. AI generators frequently produce this pattern — reject on sight.
SecTrustResultType reference
For code that must inspect results after evaluation via SecTrustGetTrustResult:
| Result | Meaning | Action |
|---|---|---|
.unspecified | Chain validates to implicitly trusted anchor | Proceed — most common success |
.proceed | User explicitly chose to trust this cert | Proceed |
.deny | User explicitly marked cert as untrusted | Reject — never override |
.recoverableTrustFailure | Failed but recovery possible | Inspect, possibly reconfigure |
.fatalTrustFailure | Fundamental certificate defect | Reject |
.otherError | Non-trust error (revoked, OS error) | Reject |
.invalid | No evaluation performed yet | Call evaluation first |
Modern SecTrustEvaluateWithError collapses this to a Boolean. Treat only .unspecified and .proceed as success.
---
Custom Trust Policy Configuration
// ✅ CORRECT: SSL policy with hostname verification
let policy = SecPolicyCreateSSL(true, "api.example.com" as CFString)
// true = server evaluation; hostname enables SNI matching
var trust: SecTrust?
SecTrustCreateWithCertificates(certificateChain as CFTypeRef, policy, &trust)// ✅ CORRECT: Custom anchor while preserving system trust store
SecTrustSetAnchorCertificates(trust, [customRootCA] as CFArray)
SecTrustSetAnchorCertificatesOnly(trust, false) // false = ALSO trust system anchors// ❌ INCORRECT: Missing SecTrustSetAnchorCertificatesOnly
SecTrustSetAnchorCertificates(trust, [customRootCA] as CFArray)
// Without SecTrustSetAnchorCertificatesOnly(trust, false), ALL system anchors
// are silently disabled — only your custom CA is trusted!// ❌ INCORRECT: nil hostname disables hostname verification entirely
let policy = SecPolicyCreateSSL(true, nil)
// Any valid certificate for ANY domain now passes — MITM vector---
Four Pinning Strategies
Leaf certificate pinning — breaks on every renewal
Commercial TLS certificates expire every 90 days (Let's Encrypt) to 398 days (CA/Browser Forum maximum). When the server renews, the certificate bytes change (new serial, validity dates, signature) and the pin breaks. Users are locked out until an App Store update ships.
// ❌ DANGEROUS: Leaf pinning that breaks on every certificate renewal
guard let chain = SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate],
let serverCert = chain.first else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let serverCertData = SecCertificateCopyData(serverCert) as Data
let localCertData = // loaded from bundle .cer file
if serverCertData == localCertData {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
// WILL fire when the certificate renews, locking out all users
completionHandler(.cancelAuthenticationChallenge, nil)
}Verdict: never use in production unless you control the full certificate lifecycle AND can update pins without App Store review.
Intermediate CA pinning — 5–10 year validity window
Pin an intermediate CA certificate. Any leaf issued by that CA passes the check. The server can freely renew its leaf certificate.
// ✅ CORRECT: Intermediate CA pinning (resilient to leaf renewal)
guard let chain = SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate] else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let pinnedIntermediateData = // load intermediate CA .cer from bundle
for cert in chain {
let certData = SecCertificateCopyData(cert) as Data
if certData == pinnedIntermediateData {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
return
}
}
completionHandler(.cancelAuthenticationChallenge, nil)Tradeoff: trusts any certificate from that CA, not just yours. If the CA is compromised, a same-CA certificate could impersonate your server.
SPKI hash pinning — survives renewal with same key pair
Hashes the SubjectPublicKeyInfo (SPKI) structure. When certificates renew with the same key pair, the SPKI stays identical. This is the recommended programmatic approach.
Critical correctness issue: SecKeyCopyExternalRepresentation returns raw key bytes without the ASN.1 SPKI header. You must prepend the correct header before hashing. Omitting this produces incorrect hashes that won't match pins generated via OpenSSL.
The code below uses current APIs with proper SPKI construction. Do not hash raw key bytes directly; they lack the ASN.1 SPKI header expected by common pin-generation workflows.
// ✅ CORRECT: SPKI hash pinning with ASN.1 header and modern APIs
class SPKIPinningDelegate: NSObject, URLSessionDelegate {
// ASN.1 headers for reconstructing SPKI from raw key data
private static let rsa2048Header: [UInt8] = [
0x30, 0x82, 0x01, 0x22, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86,
0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0f, 0x00
]
private static let ecP256Header: [UInt8] = [
0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02,
0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03,
0x42, 0x00
]
private let pinnedHashes: Set<String> // Base64(SHA256(SPKI))
init(pinnedHashes: Set<String>) {
self.pinnedHashes = pinnedHashes
}
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(.performDefaultHandling, nil)
return
}
// Step 1: ALWAYS validate the chain via system trust first
guard SecTrustEvaluateWithError(serverTrust, nil) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Step 2: Walk chain and check SPKI hashes
guard let chain = SecTrustCopyCertificateChain(serverTrust)
as? [SecCertificate] else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
for cert in chain {
if let hash = spkiHash(for: cert), pinnedHashes.contains(hash) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
return
}
}
completionHandler(.cancelAuthenticationChallenge, nil)
}
private func spkiHash(for certificate: SecCertificate) -> String? {
guard let publicKey = SecCertificateCopyKey(certificate),
let keyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data?,
let attrs = SecKeyCopyAttributes(publicKey) as? [CFString: Any],
let keyType = attrs[kSecAttrKeyType] as? String,
let keySize = attrs[kSecAttrKeySizeInBits] as? Int else { return nil }
let header: [UInt8]
switch (keyType, keySize) {
case (kSecAttrKeyTypeRSA as String, 2048): header = Self.rsa2048Header
case (kSecAttrKeyTypeRSA as String, 4096):
// Add RSA-4096 header for production use
return nil
case (kSecAttrKeyTypeECSECPrimeRandom as String, 256):
header = Self.ecP256Header
default: return nil
}
var spki = Data(header)
spki.append(keyData)
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
spki.withUnsafeBytes {
_ = CC_SHA256($0.baseAddress, CC_LONG(spki.count), &hash)
}
return Data(hash).base64EncodedString()
}
}Generate expected SPKI hashes from the command line:
# From a PEM certificate file:
openssl x509 -in cert.pem -noout -pubkey | \
openssl pkey -pubin -outform der | \
openssl dgst -sha256 -binary | openssl enc -base64
# From a live server:
openssl s_client -connect api.example.com:443 </dev/null 2>/dev/null | \
openssl x509 -pubkey -noout | \
openssl pkey -pubin -outform der | \
openssl dgst -sha256 -binary | openssl enc -base64NSPinnedDomains — declarative pinning, zero code (iOS 14+)
Apple's recommended approach. Enforced automatically by URLSession via ATS. Uses SPKI hashes.
<!-- ✅ CORRECT: CA identity pinning with backup pin via NSPinnedDomains -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSPinnedDomains</key>
<dict>
<key>api.example.com</key>
<dict>
<key>NSIncludesSubdomains</key>
<true/>
<key>NSPinnedCAIdentities</key>
<array>
<dict>
<key>SPKI-SHA256-BASE64</key>
<string>PrimaryCA_SPKI_Hash_Base64==</string>
</dict>
<dict>
<!-- Backup CA from a different provider -->
<key>SPKI-SHA256-BASE64</key>
<string>BackupCA_SPKI_Hash_Base64==</string>
</dict>
</array>
</dict>
</dict>
</dict>Available keys per pinned domain:
- `NSPinnedCAIdentities` — matches any intermediate or root in the chain (logical OR within array)
- `NSPinnedLeafIdentities` — matches the leaf certificate only
- `NSIncludesSubdomains` — covers first-level subdomains when
true
If both NSPinnedCAIdentities and NSPinnedLeafIdentities are specified, ATS requires a match in each category (AND between categories, OR within each).
Limitations: works with URLSession and WKWebView (iOS 16+ after earlier bugs were fixed). Does not work with SFSafariViewController. Pins are visible in Info.plist and cannot be updated without an app update.
Pinning Strategy Decision Matrix
| Strategy | Resilience | Specificity | Update Frequency | Best For |
|---|---|---|---|---|
| Leaf certificate | ❌ Breaks every 90–398 days | Highest — exact cert match | Every renewal | Never in production |
| Intermediate CA | ✅ 5–10 years | Medium — all certs from CA | Rarely | Single-CA-provider apps |
| SPKI hash (code) | ✅ Survives renewal with same key | High — specific key | Only on key rotation | Dynamic pinsets, custom logic |
| NSPinnedDomains | ✅ Survives renewal with same key | High — SPKI-based | Only on key rotation | Default choice for most apps |
---
SecCertificate and SecIdentity
Creating certificates from DER data
// ✅ CORRECT: Load .cer from app bundle
guard let certURL = Bundle.main.url(forResource: "server", withExtension: "cer"),
let certData = try? Data(contentsOf: certURL),
let certificate = SecCertificateCreateWithData(nil, certData as CFData) else {
fatalError("Failed to load certificate")
}
let summary = SecCertificateCopySubjectSummary(certificate) as String?
let publicKey = SecCertificateCopyKey(certificate) // iOS 12+
let derBytes = SecCertificateCopyData(certificate) as Data // Round-trip to DERSecCertificateCreateWithData accepts DER-encoded data only — not PEM. For PEM files, strip the -----BEGIN CERTIFICATE----- header/footer and Base64-decode.
Importing PKCS#12 for client certificate authentication
// ✅ CORRECT: Import .p12 and extract SecIdentity
func importIdentity(from p12Data: Data, password: String) throws -> SecIdentity {
let options: [String: Any] = [kSecImportExportPassphrase as String: password]
var rawItems: CFArray?
let status = SecPKCS12Import(p12Data as CFData, options as CFDictionary, &rawItems)
guard status == errSecSuccess,
let items = rawItems as? [[String: Any]],
let firstItem = items.first,
let identity = firstItem[kSecImportItemIdentity as String] as? SecIdentity else {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
}
return identity
}Result dictionary keys from SecPKCS12Import:
- `kSecImportItemIdentity` (
SecIdentity) — private key + certificate pair - `kSecImportItemCertChain` (
[SecCertificate]) — full certificate chain - `kSecImportItemTrust` (
SecTrust) — pre-configured trust object - `kSecImportItemKeyID` (
Data) — typically SHA-1 hash of public key
Never bundle passwords with your app. Prompt the user or read from the Keychain.
Client certificate authentication in URLSession
// ✅ CORRECT: Mutual TLS delegate handling both server trust and client cert
class MutualTLSDelegate: NSObject, URLSessionDelegate {
private let identity: SecIdentity
private let certChain: [SecCertificate]?
init(identity: SecIdentity, certChain: [SecCertificate]? = nil) {
self.identity = identity
self.certChain = certChain
}
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition,
URLCredential?) -> Void) {
switch challenge.protectionSpace.authenticationMethod {
case NSURLAuthenticationMethodClientCertificate:
let credential = URLCredential(
identity: identity,
certificates: certChain,
persistence: .forSession
)
completionHandler(.useCredential, credential)
case NSURLAuthenticationMethodServerTrust:
guard let trust = challenge.protectionSpace.serverTrust,
SecTrustEvaluateWithError(trust, nil) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
completionHandler(.useCredential, URLCredential(trust: trust))
default:
completionHandler(.performDefaultHandling, nil)
}
}
}Client certificate challenges are session-wide (URLSessionDelegate), not task-specific. Apps must manage certificates within their sandbox — they cannot access system-wide certificates installed via MDM.
Certificate chain inspection (backward-compatible)
// ✅ CORRECT: Backward-compatible chain inspection
func certificateChain(from trust: SecTrust) -> [SecCertificate] {
if #available(iOS 15.0, macOS 12.0, *) {
return SecTrustCopyCertificateChain(trust) as? [SecCertificate] ?? []
} else {
return (0..<SecTrustGetCertificateCount(trust)).compactMap {
SecTrustGetCertificateAtIndex(trust, $0)
}
}
}---
Anti-Patterns AI Code Generators Produce
| Anti-Pattern | Risk | Correct Replacement |
|---|---|---|
Using deprecated SecTrustEvaluate | No error context, deprecated iOS 13 | SecTrustEvaluateWithError or SecTrustEvaluateAsyncWithError |
| Disabling ATS globally | Enables trivial MITM, triggers App Store review | NSAllowsLocalNetworking for dev; targeted exceptions for production |
SecTrustSetAnchorCertificates without SetAnchorCertificatesOnly(_, false) | Silently disables all system anchors | Always pair both calls |
SecPolicyCreateSSL with nil hostname | Disables hostname verification — MITM vector | Always pass the actual expected hostname |
| Skipping system trust eval before pin checks | Expired/revoked certs pass pin checks | Always SecTrustEvaluateWithError first, then check pins |
Using SecTrustGetCertificateAtIndex | Deprecated iOS 15 | SecTrustCopyCertificateChain (with backward-compat fallback) |
Using SecTrustCopyPublicKey | Deprecated iOS 14 | SecCertificateCopyKey or SecTrustCopyKey |
| SPKI hashing without ASN.1 header | Produces wrong hash, pins never match | Prepend correct ASN.1 SPKI header before SHA-256 |
Evaluating trust on .main queue | UI freezes during network-dependent checks | Always use background dispatch queue |
<!-- ❌ DANGEROUS: Never ship this -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<!-- ✅ CORRECT: Local networking only for development -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>---
Backup Pins, Rotation, and Graceful Degradation
Always include at least two pins. A single pin means any certificate revocation, CA compromise, or unplanned key rotation bricks your app's networking.
Backup strategy: pre-generate a backup key pair, compute its SPKI hash, include it as a pin — without deploying the corresponding certificate. If the primary key is compromised, issue a certificate for the backup key server-side. The app already trusts it.
When all pins fail: display a clear error that server credentials could not be verified, switch to offline/cached mode, never allow the user to bypass the pin, log for diagnostics. Recovery requires an App Store update (consumer apps) or MDM profile update (managed deployments).
OWASP's current nuanced position: pinning should only be done when you control both client and server, can update the pinset securely, and have a clear rotation strategy. Certificate Transparency (enforced on Apple platforms since iOS 12.1.1) plus Apple's revocation infrastructure provides substantial protection without pinning's operational risk.
---
ATS Interaction Points
ATS enforces TLS 1.2+, 2048-bit RSA or 256-bit ECC keys, SHA-256+ hashing, AES-128/256, and forward secrecy on all URLSession connections.
iOS 17 change: ATS now requires HTTPS for connections to bare IP addresses (not just domain names).
Keys that trigger additional App Store review: NSAllowsArbitraryLoads, NSAllowsArbitraryLoadsForMedia, NSAllowsArbitraryLoadsInWebContent, NSExceptionAllowsInsecureHTTPLoads, NSExceptionMinimumTLSVersion.
Use nscurl --ats-diagnostics https://your-server.com on macOS to diagnose ATS compatibility.
---
API Deprecation Timeline
| OS Version | Year | Key Changes |
|---|---|---|
| iOS 12 / macOS 10.14 | 2018 | SecTrustEvaluateWithError introduced; Certificate Transparency enforced (iOS 12.1.1) |
| iOS 13 / macOS 10.15 | 2019 | SecTrustEvaluateAsyncWithError introduced; SecTrustEvaluate deprecated |
| iOS 14 / macOS 11 | 2020 | `NSPinnedDomains` introduced; SecTrustCopyKey replaces SecTrustCopyPublicKey |
| iOS 15 / macOS 12 | 2021 | `SecTrustCopyCertificateChain` replaces SecTrustGetCertificateAtIndex/Count |
| iOS 17 / macOS 14 | 2023 | ATS enforced for IP addresses; EAP-TLS 1.3 support |
| iOS 18 / macOS 15 | 2024 | Swift 6 strict concurrency affects callback-based Security code |
| iOS 26 / macOS 26 | 2025 | Existing SecTrustEvaluateWithError / SecTrustEvaluateAsyncWithError patterns remain current |
---
Thread Safety and Performance
- SecTrust objects are thread-safe only across different instances. Never access the same
SecTrustfrom multiple threads. - Different
SecTrustobjects can be evaluated concurrently on different threads. - On iOS, all Certificate/Key/Trust Services functions are thread-safe and reentrant.
- On macOS, trust evaluation can block on user interaction (keychain unlock dialogs) — always evaluate on background threads.
SecTrust,SecCertificate, andSecKeyare not markedSendable. With Swift 6 strict concurrency, use@unchecked Sendablewrappers or explicit actor isolation.
---
CI/CD Guardrails
- Fail builds if
NSAllowsArbitraryLoadsistruein productionInfo.plist. - Validate that
SecPolicyCreateSSLis never called with anilhostname in production code paths. - Enforce that any
NSPinnedDomainsentry contains at least two SPKI hashes (backup pin requirement). - Scan for deprecated APIs:
SecTrustEvaluate(,SecTrustGetCertificateAtIndex(,SecTrustCopyPublicKey(. - Test pinning with certificate rotation in staging before production deployment.
---
Cross-References
keychain-item-classes.md—kSecClassCertificateandkSecClassIdentitystorage, PKCS#12 import patternskeychain-fundamentals.md— SecItem CRUD patterns for certificate and identity persistencecryptokit-public-key.md— PEM/DER key interoperability, curve selection for client certificatescompliance-owasp-mapping.md— M5 (Insecure Communication) trust evaluation requirements
---
WWDC and Reference Citations
- WWDC 2017 Session 709 — "Your Apps and Evolving Network Security Standards" (ATS, CT, pinning guidance)
- Apple Developer Documentation — "Evaluating a Trust and Parsing the Result",
SecTrustEvaluateAsyncWithError,NSPinnedDomains - Apple Platform Security Guide — Revocation infrastructure, Certificate Transparency
- Apple News Article — "Identity Pinning: How to configure server certificates for your app"
- OWASP Pinning Cheat Sheet — Strategy recommendations, backup pin guidance
- OWASP MASTG — Certificate pinning test cases
---
Summary Checklist
1. Trust evaluation uses modern API — SecTrustEvaluateWithError (sync) or SecTrustEvaluateAsyncWithError (async); no deprecated SecTrustEvaluate 2. Trust evaluation runs off main thread — background dispatch queue for async; URLSession delegate callbacks already off-main for sync 3. Pinning strategy avoids leaf certificates — use SPKI hash pinning, intermediate CA pinning, or NSPinnedDomains; never pin raw leaf certificate bytes in production 4. At least two pins configured — primary + backup from different CA or pre-generated backup key pair 5. System trust evaluated before pin checks — always call SecTrustEvaluateWithError first, then compare SPKI hashes; never skip chain validation 6. SPKI hashing includes ASN.1 header — prepend correct algorithm-specific header before SHA-256 hashing raw key bytes from SecKeyCopyExternalRepresentation 7. Custom anchors preserve system trust — SecTrustSetAnchorCertificates paired with SecTrustSetAnchorCertificatesOnly(_, false) unless intentionally restricting 8. SSL policy binds hostname — SecPolicyCreateSSL always receives actual expected hostname, never nil 9. ATS not globally disabled — no NSAllowsArbitraryLoads: true in production; use targeted exceptions (NSAllowsLocalNetworking, per-domain exceptions) 10. Chain inspection uses current APIs — SecTrustCopyCertificateChain (iOS 15+) with fallback to SecTrustGetCertificateAtIndex for older targets; SecCertificateCopyKey not SecTrustCopyPublicKey 11. Client certificate passwords not bundled — PKCS#12 passwords prompted at runtime or stored in Keychain, never hardcoded or embedded in app bundle
Common Anti-Patterns
Scope: The 10 most dangerous security anti-patterns that AI coding assistants generate for iOS apps. Each entry includes the vulnerability explanation, realistic ❌ insecure code, ✅ correct replacement, detection heuristic, and OWASP risk mapping. This is the skill's backbone — the single most important file for correcting AI-generated security code.
>
Cross-references:biometric-authentication.md(anti-pattern #3 deep dive),keychain-fundamentals.md(anti-pattern #4 CRUD patterns),keychain-access-control.md(anti-pattern #5 protection classes),cryptokit-symmetric.md(anti-patterns #6–7),credential-storage-patterns.md(anti-patterns #1–2 token lifecycle),migration-legacy-stores.md(anti-pattern #9 first-launch cleanup),compliance-owasp-mapping.md(full OWASP/MASVS mapping).
---
Contents
- Why AI Generates Insecure iOS Code
- Anti-Pattern #1 — Storing Secrets in UserDefaults
- Anti-Pattern #2 — Hardcoded API Keys
- Anti-Pattern #3 — LAContext-Only Biometric Authentication
- Anti-Pattern #4 — Ignoring SecItem Error Codes
- Anti-Pattern #5 — Wrong or Missing Data Protection Class
- Anti-Pattern #6 — Nonce Reuse in AES-GCM
- Anti-Pattern #7 — MD5/SHA-1 for Security Purposes
- Anti-Pattern #8 — Logging Sensitive Data
- Anti-Pattern #9 — Not Clearing Keychain on First Launch
- Anti-Pattern #10 — Non-Cryptographic RNG for Security Operations
- Quick Reference Matrix
- CI/CD Detection Strategy
- iOS 26 / WWDC 2025 Implications
- Summary Checklist
Why AI Generates Insecure iOS Code
AI assistants optimize for functional correctness, not security — reproducing the most common patterns from training data, which are overwhelmingly insecure-by-default. Veracode's 2025 analysis: 45% of AI-generated code fails security tests. Cybernews: 815,000+ hardcoded secrets across 156,000 iOS apps (71% leaking ≥1 credential). Stanford: developers using AI write less secure code yet feel more confident.
Apple's security primitives (Keychain, CryptoKit, Secure Enclave) are excellent but AI consistently bypasses them. CISA/FBI classified hardcoded credentials as elevating "risk to national security" in their January 2025 Bad Practices v2.0 (CWE-798).
OWASP standard: Mobile Top 10 (2024) with MASTG v2 test IDs. Legacy MSTG-\* identifiers noted where commonly referenced.
---
Anti-Pattern #1 — Storing Secrets in UserDefaults
Severity: CRITICAL | OWASP: M9 (Insecure Data Storage) | Fix effort: Medium
UserDefaults writes to an unencrypted XML plist at ~/Library/Preferences/{BUNDLE_ID}.plist. Apple's documentation: "Don't store personal or sensitive information as settings." Readable from unencrypted backups, jailbroken devices (Objection ios nsuserdefaults get), and third-party SDKs. SwiftUI's `@AppStorage` is a wrapper over `UserDefaults` — it has identical security properties and must never be used for tokens, keys, or credentials.
❌ Insecure — AI-generated pattern:
// Plaintext on disk, readable from backups
func saveAuthToken(_ token: String) {
UserDefaults.standard.set(token, forKey: "userAuthToken")
UserDefaults.standard.set(refreshToken, forKey: "refreshToken")
UserDefaults.standard.synchronize()
}
let token = UserDefaults.standard.string(forKey: "userAuthToken")✅ Secure — Keychain with add-or-update:
func saveTokenToKeychain(_ token: Data, account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.myapp.auth",
kSecAttrAccount as String: account,
kSecValueData as String: token,
kSecAttrAccessible as String:
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecDuplicateItem {
// Full add-or-update pattern → see anti-pattern #4
let search: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.myapp.auth",
kSecAttrAccount as String: account
]
let updateStatus = SecItemUpdate(
search as CFDictionary,
[kSecValueData as String: token] as CFDictionary)
guard updateStatus == errSecSuccess else {
throw KeychainError.unexpectedStatus(updateStatus)
}
} else if status != errSecSuccess {
throw KeychainError.unexpectedStatus(status)
}
}MASTG tests: MASTG-TEST-0300, MASTG-TEST-0302. MASWE: MASWE-0006. Legacy: MSTG-STORAGE-1.
Detection heuristic:
grep -rn "UserDefaults" --include="*.swift" | \
grep -iE "token|password|secret|credential|auth|session|api.?key|jwt|bearer"---
Anti-Pattern #2 — Hardcoded API Keys
Severity: CRITICAL | OWASP: M1 (Improper Credential Usage) | Fix effort: High
API keys compiled into Swift appear in the binary's __TEXT.__cstring segment — strings MyApp.app/MyApp extracts them instantly. Even .xcconfig or Info.plist values ship inside the IPA. Cybernews found 78,800 Google API keys across 156,000 iOS apps.
❌ Insecure — AI-generated pattern:
class PaymentService {
private let stripeKey = "sk_live_51H7bK2E..." // In binary
private let firebaseKey = "AIzaSyB..." // In binary
func charge(amount: Int) async throws {
var request = URLRequest(
url: URL(string: "https://api.stripe.com/v1/charges")!)
request.setValue("Bearer \(stripeKey)",
forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
}
}
// Also dangerous: key in Info.plist or .xcconfig bundled in app
let key = Bundle.main.infoDictionary?["API_KEY"] as? String✅ Secure — server proxy + Keychain cache:
class SecureAPIKeyManager {
static let shared = SecureAPIKeyManager()
/// Best: proxy through your server (key never on device)
func secureRequest(endpoint: String, params: [String: Any]) async throws -> Data {
var request = URLRequest(
url: URL(string: "https://api.myserver.com/proxy/\(endpoint)")!)
request.httpMethod = "POST"
request.httpBody = try JSONSerialization.data(withJSONObject: params)
let (data, _) = try await URLSession.shared.data(for: request)
return data
}
/// If client must hold key: fetch at runtime, cache in Keychain
func getAPIKey() async throws -> String {
if let cached = try? readFromKeychain(service: "api-keys", account: "primary") {
return String(data: cached, encoding: .utf8)!
}
let (data, _) = try await URLSession.shared.data(
from: URL(string: "https://api.myserver.com/config/key")!)
try saveToKeychain(data, service: "api-keys", account: "primary")
return String(data: data, encoding: .utf8)!
}
}Apple's DeviceCheck and App Attest frameworks provide server-side device verification without embedding secrets. WWDC 2019-709 advises storing credentials in Keychain, not in code.
MASTG tests: MASTG-TEST-0213, MASTG-TEST-0214. MASWE: MASWE-0005. Legacy: MSTG-STORAGE-12. CISA/FBI: CWE-798 — Product Security Bad Practices v2.0 (January 2025).
Detection heuristic:
grep -rn 'let.*[Kk]ey.*=.*"[A-Za-z0-9_\-]\{20,\}"' --include="*.swift"
grep -rn '"sk_live_\|"pk_live_\|"AIza[A-Za-z0-9]\|"AKIA[A-Z0-9]' \
--include="*.swift" --include="*.plist" --include="*.xcconfig"---
Anti-Pattern #3 — LAContext-Only Biometric Authentication
Severity: CRITICAL | OWASP: M3 (Insecure Authentication) | Fix effort: Medium
Using LAContext.evaluatePolicy() alone is the single most reproduced insecure pattern across iOS tutorials. The method returns a simple boolean callback in user-space — no cryptographic binding. Frida forces success = true in one command; Objection packages this as ios ui biometrics_bypass. OWASP MASTG: "Biometric authentication must be based on unlocking the keychain." Full deep dive: see biometric-authentication.md.
❌ Insecure — AI-generated pattern:
func authenticateUser(completion: @escaping (Bool) -> Void) {
let context = LAContext()
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Authenticate to access your account"
) { success, authError in
DispatchQueue.main.async {
if success {
self.showSensitiveData() // Gated on a hookable boolean
}
completion(success)
}
}
}✅ Secure — Keychain + SecAccessControl hardware binding:
// STORE: biometric-protected via Secure Enclave
func storeWithBiometric(secret: Data, account: String) throws {
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet, nil)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.myapp.biometric",
kSecAttrAccount as String: account,
kSecAttrAccessControl as String: access,
kSecValueData as String: secret
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess || status == errSecDuplicateItem else {
throw KeychainError.unexpectedStatus(status)
}
}
// READ: Secure Enclave enforces biometric before releasing data
func readWithBiometric(account: String) throws -> Data {
let context = LAContext()
context.localizedReason = "Access your secure data"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.myapp.biometric",
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 {
throw KeychainError.unexpectedStatus(status)
}
return data // Only returned after hardware biometric validation
}The .biometryCurrentSet flag invalidates the item if biometrics change, preventing an attacker with physical access from enrolling their own biometric. Objection's documentation confirms this bypass "will NOT work" with keychain-bound biometric items.
MASTG tests: MASTG-TEST-0266, MASTG-TEST-0267. MASWE: MASWE-0044. Legacy: MSTG-AUTH-8. WWDC: 2014-711 introduced SecAccessControlCreateWithFlags.
Detection heuristic:
# evaluatePolicy without SecAccessControl → insecure
grep -rn "evaluatePolicy" --include="*.swift" -l | \
xargs grep -L "SecAccessControlCreateWithFlags"
# Verify secure pattern exists
grep -rn "\.biometryCurrentSet\|\.biometryAny" --include="*.swift"---
Anti-Pattern #4 — Ignoring SecItem Error Codes
Severity: HIGH | OWASP: M8 (Security Misconfiguration) | Fix effort: Low
errSecDuplicateItem (OSStatus -25299) is the most common Keychain failure. When SecItemAdd hits a duplicate, it silently discards the new value. Password updates never persist, refreshed tokens are lost, and auth breaks in hard-to-debug ways. Other critical codes: errSecItemNotFound (-25300), errSecAuthFailed (-25293), errSecInteractionNotAllowed (-25308).
Full CRUD patterns: see keychain-fundamentals.md.
❌ Insecure — AI-generated pattern:
func saveToken(_ token: Data) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.auth",
kSecAttrAccount as String: "accessToken",
kSecValueData as String: token
]
SecItemAdd(query as CFDictionary, nil) // Return value ignored!
}✅ Secure — OSStatus switch with add-or-update:
func saveToKeychain(value: Data, service: String, account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: value,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
let status = SecItemAdd(query as CFDictionary, nil)
switch status {
case errSecSuccess: return
case errSecDuplicateItem:
let search: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let updateStatus = SecItemUpdate(
search as CFDictionary, [kSecValueData as String: value] as CFDictionary)
guard updateStatus == errSecSuccess else { throw KeychainError.updateFailed(updateStatus) }
case errSecInteractionNotAllowed: throw KeychainError.deviceLocked
case errSecAuthFailed: throw KeychainError.authenticationFailed
default: throw KeychainError.unexpectedStatus(status)
}
}Critical detail: SecItemUpdate takes two dictionaries — search query (without kSecValueData) and attributes to update. Passing the full query as the search parameter is a common mistake.
MASTG tests: MASTG-TEST-0300, MASTG-TEST-0301. Legacy: MASVS-STORAGE-2.
Detection heuristic:
grep -rn "SecItemAdd" --include="*.swift" -l | \
xargs grep -L "errSecDuplicateItem\|DuplicateItem\|-25299"
grep -rn "SecItemAdd(" --include="*.swift" | \
grep -v "let\|var\|status\|=\|switch\|if\|guard"---
Anti-Pattern #5 — Wrong or Missing Data Protection Class
Severity: HIGH | OWASP: M9 (Insecure Data Storage) | Fix effort: Low
Omitting kSecAttrAccessible inherits a default that may be insufficient. Using deprecated kSecAttrAccessibleAlways (deprecated iOS 12) leaves data decryptable on a locked device. Missing ThisDeviceOnly suffix means items are included in backups. Full protection class guide: see keychain-access-control.md.
❌ Insecure — AI-generated patterns:
// Missing kSecAttrAccessible entirely
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "user_password",
kSecValueData as String: passwordData
]
SecItemAdd(query as CFDictionary, nil)
// Deprecated — accessible when device is locked
kSecAttrAccessible as String: kSecAttrAccessibleAlways✅ Secure — selection by use case:
// Passwords, auth tokens (foreground-only)
kSecAttrAccessible as String:
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
// Highest sensitivity — requires passcode to exist
kSecAttrAccessible as String:
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
// Background-access items (push tokens, refresh tokens)
kSecAttrAccessible as String:
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnlyWWDC 2014-711: "Always use the most restrictive option that makes sense for your app."
MASTG test: MASTG-TEST-0299. Legacy: MASTG-STORAGE-3.
Detection heuristic:
grep -rn "kSecAttrAccessibleAlways\b" --include="*.swift"
grep -rn "SecItemAdd" --include="*.swift" -l | \
xargs grep -L "kSecAttrAccessible\|kSecAttrAccessControl"
grep -rn "kSecAttrAccessibleWhenUnlocked\b" --include="*.swift" | \
grep -v "ThisDeviceOnly"---
Anti-Pattern #6 — Nonce Reuse in AES-GCM
Severity: CRITICAL | OWASP: M10 (Insufficient Cryptography) | Fix effort: Medium
Reusing a nonce with the same key in AES-GCM is a complete cryptographic break. Identical nonces produce identical keystreams, enabling plaintext recovery via C1 ⊕ C2 = P1 ⊕ P2 and authentication key recovery via polynomial factorization ("forbidden attack," Joux 2006). CryptoKit's AES.GCM.seal has a safe default: omitting the nonce parameter auto-generates a random 12-byte nonce. Danger occurs when AI explicitly constructs nonces. Full patterns: see cryptokit-symmetric.md.
❌ Insecure — AI-generated patterns:
import CryptoKit
// Hardcoded nonce — identical keystream every encryption
let fixedNonce = try! AES.GCM.Nonce(data: Data(repeating: 0x00, count: 12))
func encrypt(_ plaintext: Data, using key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.seal(
plaintext, using: key, nonce: fixedNonce) // CATASTROPHIC
return sealedBox.combined!
}
// Also dangerous: counter-based nonce that resets on app restart → collision✅ Secure — let CryptoKit handle nonces:
import CryptoKit
func encrypt(_ plaintext: Data, using key: SymmetricKey) throws -> Data {
// Nonce omitted → CryptoKit generates random 12-byte nonce
let sealedBox = try AES.GCM.seal(plaintext, using: key)
return sealedBox.combined! // Contains: nonce ‖ ciphertext ‖ tag
}
func decrypt(_ combined: Data, using key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.SealedBox(combined: combined)
return try AES.GCM.open(sealedBox, using: key)
}
let key = SymmetricKey(size: .bits256) // AES-256 per WWDC 2025 guidanceWWDC 2019-709 introduced CryptoKit with the design philosophy: "easy to use, hard to misuse."
MASTG test: MASTG-TEST-0317. MASWE: MASWE-0022. Legacy: MASTG-CRYPTO-4.
Detection heuristic:
grep -rn "AES\.GCM\.Nonce(data:" --include="*.swift"
grep -rn "let.*nonce.*=.*AES\.GCM\.Nonce" --include="*.swift"
grep -rn "Data(repeating:.*count:\s*12)" --include="*.swift"
grep -rn "\.seal(.*nonce:" --include="*.swift"---
Anti-Pattern #7 — MD5/SHA-1 for Security Purposes
Severity: HIGH | OWASP: M10 (Insufficient Cryptography) | Fix effort: Low
MD5 broken since Wang & Yu (2005); SHA-1 broken by SHAttered (2017). CISA January 2025 lists both as insecure. Apple signals this via CryptoKit's Insecure.MD5 and Insecure.SHA1 namespacing.
❌ Insecure — AI-generated pattern:
import CryptoKit
func hashPassword(_ password: String) -> String {
let hash = Insecure.MD5.hash(data: password.data(using: .utf8)!)
return hash.map { String(format: "%02x", $0) }.joined()
}
// Also: CC_MD5, CC_SHA1 from CommonCrypto✅ Secure — SHA-256 minimum, KDF for passwords:
import CryptoKit
// Integrity verification
func hashData(_ data: Data) -> String {
let hash = SHA256.hash(data: data)
return hash.map { String(format: "%02x", $0) }.joined()
}
// HMAC for message authentication
func authenticate(_ data: Data, key: SymmetricKey) -> Data {
Data(HMAC<SHA256>.authenticationCode(for: data, using: key))
}
// Password storage — NEVER raw hashes. Use a KDF:
// Server-side: Argon2id, bcrypt, or scrypt
// On-device: PBKDF2 with ≥600,000 iterations (OWASP 2023 minimum for HMAC-SHA256)
// See cryptokit-symmetric.md for full PBKDF2 implementationiOS 26 adds SHA-3 family (SHA3_256, SHA3_384, SHA3_512) in CryptoKit. WWDC 2025-314 covers post-quantum additions (ML-KEM, ML-DSA), not SHA-3.
MASTG test: MASTG-TEST-0211. MASTG demos: MASTG-DEMO-0015, MASTG-DEMO-0016. Legacy: MSTG-CRYPTO-1.
Detection heuristic:
grep -rn "Insecure\.\(MD5\|SHA1\)" --include="*.swift"
grep -rn "CC_MD5\|CC_SHA1\|CC_MD5_DIGEST_LENGTH\|CC_SHA1_DIGEST_LENGTH" \
--include="*.swift" --include="*.m"---
Anti-Pattern #8 — Logging Sensitive Data
Severity: HIGH | OWASP: M9 (Insecure Data Storage) | Fix effort: Low
print(), NSLog(), and os_log() with sensitive values persist in device logs — accessible via Xcode Console, idevicesyslog, and log collect --device. On jailbroken devices, any process reads log storage. Apple's OSLogPrivacy (iOS 14+): .private redacts in production; .sensitive (iOS 15+) always redacted.
❌ Insecure — AI-generated pattern:
func login(username: String, password: String) async throws {
print("Logging in with password: \(password)") // In device logs!
let token = try await authService.authenticate(username, password)
print("Got auth token: \(token)") // In device logs!
os_log("API key loaded: %{public}@", apiKey) // Explicitly public!
}✅ Secure — OSLogPrivacy with redaction:
import os
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "auth")
func login(username: String, password: String) async throws {
// Log events, not values — .private(mask: .hash) enables correlation
logger.info("Login attempt: \(username, privacy: .private(mask: .hash))")
let token = try await authService.authenticate(username, password)
logger.info("Authentication succeeded") // No token value
}
// Legacy os_log
os_log("Account: %{private}@", log: .default, type: .info, accountNumber)
// Strip debug logging in release builds
#if DEBUG
print("Debug: \(sensitiveValue)")
#endifMASTG tests: MASTG-TEST-0296, MASTG-TEST-0297. MASWE: MASWE-0001. Legacy: MSTG-STORAGE-3.
Detection heuristic:
grep -rn "print(.*\\\(" --include="*.swift" | \
grep -iE "password|token|secret|key|credential|ssn|credit"
grep -rn "NSLog(.*%@" --include="*.swift" --include="*.m" | \
grep -iE "password|token|secret|key"
grep -rn 'os_log.*%{public}' --include="*.swift" | \
grep -iE "password|token|secret|key"---
Anti-Pattern #9 — Not Clearing Keychain on First Launch
Severity: MEDIUM | OWASP: M9 (Insecure Data Storage) | Fix effort: Low
Keychain items persist in a system-wide encrypted database managed by securityd, outside the app sandbox. App deletion removes the sandbox but keychain items survive. Apple DTS engineer Quinn "The Eskimo!" confirmed this as "currently expected behaviour despite being an obvious privacy concern." Consequences: stale credentials on reinstall, cross-user data leakage on device resale, and Firebase SDK authentication errors on reinstall. Full migration patterns: see migration-legacy-stores.md.
❌ The missing pattern — AI never generates this:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup { ContentView() }
}
// Stale keychain items from previous install persist silently
}✅ Secure — first-launch keychain cleanup:
@main
struct MyApp: App {
init() { clearKeychainIfFirstLaunch() }
var body: some Scene {
WindowGroup { ContentView() }
}
private func clearKeychainIfFirstLaunch() {
let defaults = UserDefaults.standard
guard !defaults.bool(forKey: "hasLaunchedBefore") else { return }
// UserDefaults was cleared on uninstall → this is first launch
for secClass in [kSecClassGenericPassword, kSecClassInternetPassword,
kSecClassCertificate, kSecClassKey, kSecClassIdentity] {
SecItemDelete([
kSecClass: secClass,
kSecAttrSynchronizable: kSecAttrSynchronizableAny
] as NSDictionary)
}
defaults.set(true, forKey: "hasLaunchedBefore")
}
}Place this before initializing any SDKs (Firebase, analytics) that read from Keychain. Including kSecAttrSynchronizableAny ensures iCloud Keychain items are also cleared.
MASTG tests: MASTG-TEST-0300, MASTG-TEST-0301. Legacy: MSTG-STORAGE-11.
Detection heuristic:
grep -rn "SecItemAdd\|SecItemCopyMatching" --include="*.swift" -l | \
xargs grep -L "hasLaunchedBefore\|isFirstLaunch\|firstRun"
grep -rn "SecItemDelete" --include="*.swift" -l | \
xargs grep "hasLaunchedBefore\|isFirstLaunch"---
Anti-Pattern #10 — Non-Cryptographic RNG for Security Operations
Severity: HIGH | OWASP: M10 (Insufficient Cryptography) | Fix effort: Low
arc4random() returns only 32-bit UInt32 — insufficient for cryptographic purposes requiring 128–256 bits. Character-by-character token construction introduces bias. Truly non-cryptographic alternatives (rand(), drand48(), GameplayKit RNG) must never be used for security operations.
❌ Insecure — AI-generated patterns:
func generateToken() -> String {
return String(arc4random_uniform(999_999)) // ~20 bits of entropy
}
func generateSessionId(length: Int = 16) -> String {
let chars = "abcdefghijklmnopqrstuvwxyz0123456789"
return String((0..<length).map { _ in chars.randomElement()! }) // Bias
}
// Also dangerous: srand48/drand48, rand(), GameplayKit RNG✅ Secure — SecRandomCopyBytes / CryptoKit:
import Security
import CryptoKit
// SecRandomCopyBytes — canonical iOS crypto RNG
func generateSecureToken(byteCount: Int = 32) throws -> String {
var bytes = [UInt8](repeating: 0, count: byteCount)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
guard status == errSecSuccess else {
throw CryptoError.randomGenerationFailed(status)
}
return bytes.map { String(format: "%02x", $0) }.joined()
}
// CryptoKit key generation (secure RNG internally)
let encryptionKey = SymmetricKey(size: .bits256)SecRandomCopyBytes sources entropy from the Secure Enclave's hardware TRNG via corecrypto's ccrng_generate. It reports errors via return status — unlike arc4random, which silently cannot fail.
MASTG test: MASTG-TEST-0311. MASTG demos: MASTG-DEMO-0073, MASTG-DEMO-0074. Legacy: MSTG-CRYPTO-6.
Detection heuristic:
grep -rn "arc4random\|arc4random_uniform\|arc4random_buf" --include="*.swift" | \
grep -iE "token|nonce|salt|key|secret|session|iv"
grep -rn "\bsrand\b\|\brand()\|\brandom()\|\bdrand48\b" --include="*.swift"
grep -rn "GKARC4RandomSource\|GKMersenneTwisterRandomSource" --include="*.swift"---
Quick Reference Matrix
| # | Anti-Pattern | OWASP 2024 | MASTG Test | Dangerous API | Secure API | Fix Effort |
|---|---|---|---|---|---|---|
| 1 | UserDefaults secrets | M9 | MASTG-TEST-0302 | UserDefaults.set | SecItemAdd + Keychain | Medium |
| 2 | Hardcoded API keys | M1 | MASTG-TEST-0213 | String literals | Server proxy + Keychain cache | High |
| 3 | LAContext-only biometric | M3 | MASTG-TEST-0266 | evaluatePolicy | SecAccessControlCreateWithFlags | Medium |
| 4 | Ignored SecItem errors | M8 | MASTG-TEST-0300 | Unchecked SecItemAdd | OSStatus switch + SecItemUpdate | Low |
| 5 | Wrong data protection | M9 | MASTG-TEST-0299 | kSecAttrAccessibleAlways | WhenUnlockedThisDeviceOnly | Low |
| 6 | Nonce reuse AES-GCM | M10 | MASTG-TEST-0317 | AES.GCM.Nonce(data:) | Omit nonce (auto-random) | Medium |
| 7 | MD5/SHA-1 for security | M10 | MASTG-TEST-0211 | Insecure.MD5/.SHA1 | SHA256+ / KDF for passwords | Low |
| 8 | Logging sensitive data | M9 | MASTG-TEST-0297 | print(token) | Logger + .private | Low |
| 9 | No keychain cleanup | M9 | MASTG-TEST-0300 | Missing cleanup | UserDefaults flag + SecItemDelete | Low |
| 10 | Non-crypto RNG | M10 | MASTG-TEST-0311 | arc4random() | SecRandomCopyBytes | Low |
---
CI/CD Detection Strategy
Semgrep (pre-commit/PR gate): Fast structural pattern matching for UserDefaults misuse, missing errSecDuplicateItem, LAContext booleans. Limited data-flow analysis.
CodeQL (nightly/PR gate): Deep semantic taint tracking — catches tokens assigned to variables then logged. Slower execution.
Binary scanning (post-build): strings/class-dump on compiled binary catches hardcoded keys surviving source-level obfuscation.
Recommended: Semgrep on every PR + post-build binary scanning. CodeQL nightly for deep analysis.
---
iOS 26 / WWDC 2025 Implications
WWDC 2025-314 introduced the most significant CryptoKit expansion since 2019:
- Symmetric keys:
.bits256recommended over.bits128for quantum resistance (anti-patterns #6, #10) - Hashing: SHA-3 family (
SHA3_256/384/512) in CryptoKit on iOS 26+ (anti-pattern #7) - Post-quantum: ML-KEM 768/1024, ML-DSA 65/87, X-Wing — all with Secure Enclave support
- TLS:
X25519MLKEM768enabled by default forURLSessionin iOS 26 - Secure Enclave: Hardware post-quantum key creation strengthens anti-patterns #3 and #5 fixes
---
Summary Checklist
When reviewing iOS code for security anti-patterns, verify each item:
1. No secrets in UserDefaults — tokens, passwords, API keys, JWTs use Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly or stricter 1. No hardcoded keys in source — API keys fetched at runtime via server proxy or authenticated endpoint; no high-entropy string literals, no secrets in .xcconfig or Info.plist 1. Biometrics bound to Keychain — evaluatePolicy is never used alone to gate sensitive actions; SecAccessControlCreateWithFlags with .biometryCurrentSet protects keychain items 1. All SecItem calls checked — SecItemAdd handles errSecDuplicateItem with SecItemUpdate fallback; SecItemCopyMatching handles errSecItemNotFound; no discarded OSStatus return values 1. Explicit data protection class — every SecItemAdd includes kSecAttrAccessible or kSecAttrAccessControl; no kSecAttrAccessibleAlways; ThisDeviceOnly variants used for non-syncing items 1. No nonce reuse — AES.GCM.seal called without explicit nonce: parameter (auto-random); no stored/global/counter-based nonce variables 1. No broken hashes — no Insecure.MD5, Insecure.SHA1, CC_MD5, CC_SHA1 for security purposes; passwords use a password KDF such as Argon2id, bcrypt, or PBKDF2-HMAC-SHA256 with at least 600,000 iterations 1. No sensitive data in logs — print() and NSLog() never contain tokens, keys, or credentials; os_log uses %{private}@; Logger uses .private or .private(mask: .hash) 1. First-launch keychain cleanup — UserDefaults flag + SecItemDelete for all classes runs before SDK initialization at app startup 1. Cryptographic RNG only — SecRandomCopyBytes or CryptoKit APIs for tokens, nonces, salts, keys; no arc4random / rand() / drand48() / GameplayKit RNG in security contexts 1. iOS 26 readiness — symmetric keys use .bits256; no deprecated algorithms; aware of post-quantum CryptoKit APIs for forward-looking implementations
Related skills
How it compares
Use Swift Security for iOS Keychain and token storage review; use general OWASP mobile guides when auditing full app threat models beyond credential code.
FAQ
What does swift-security do?
Use when working with iOS/macOS Keychain Services (SecItem queries, kSecClass, OSStatus errors), biometric authentication (LAContext, Face ID, Touch ID), CryptoKit (AES-GCM, ChaChaPoly, ECDSA, ECDH, H
When should I use swift-security?
During ship testing work for testing & qa.
Is swift-security safe to install?
Review the Security Audits panel on this listing before production use.