
Swift Security Expert
- 647 installs
- 29 repo stars
- Updated June 17, 2026
- ivan-magda/swift-security-skill
swift-security-expert is a Claude Code skill that reviews and implements iOS/macOS Keychain, biometric auth, CryptoKit, and certificate-pinning code against Apple docs and OWASP MASTG.
About
swift-security-expert is a reference skill for reviewing and implementing client-side security on Apple platforms. It covers iOS/macOS Keychain Services, biometric authentication with LAContext and Face ID/Touch ID, CryptoKit cryptography, Secure Enclave, certificate pinning, and OWASP MASVS/MASTG compliance mapping. Developers use it to audit existing keychain and credential code, migrate secrets off UserDefaults or plists, or implement secure storage from scratch. It ships fifteen reference files and a decision tree that routes tasks into review, improve, or implement branches.
- iOS/macOS Keychain and CryptoKit security reference
- Review, improve, and implement branches
- OWASP MASVS/MASTG compliance mapping
Swift Security Expert by the numbers
- 647 all-time installs (skills.sh)
- +28 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #464 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
swift-security-expert capabilities & compatibility
Free reference skill, no API key required.
- Capabilities
- security audit · keychain review · crypto implementation · compliance mapping · secret migration
- Use cases
- security audit · code review
- Pricing
- Free
What swift-security-expert says it does
Use when working with iOS/macOS Keychain Services (SecItem queries, kSecClass, OSStatus errors), biometric authentication (LAContext, Face ID, Touch ID), CryptoKit
A reference for reviewing, improving, and implementing keychain operations, biometric authentication, CryptoKit cryptography, credential lifecycle management, certificate trust, and compliance mapping
npx skills add https://github.com/ivan-magda/swift-security-skill --skill swift-security-expertAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 647 |
|---|---|
| repo stars | ★ 29 |
| Last updated | June 17, 2026 |
| Repository | ivan-magda/swift-security-skill ↗ |
How do I store secrets, credentials, and keys correctly on iOS/macOS without leaking them through UserDefaults, weak crypto, or ignored OSStatus errors?
security-audit
Who is it for?
iOS and macOS developers auditing or building keychain, biometric, and cryptography code who need Apple-documented, correctness-focused patterns.
Skip if: Developers needing server-side security, TLS/ATS networking configuration, or backend auth architecture, which are explicitly out of scope.
When should I use this skill?
Working with Keychain Services, LAContext biometrics, CryptoKit, Secure Enclave, certificate pinning, secret migration, or OWASP MASVS compliance on Apple platforms.
What you get
Keychain, biometric, and CryptoKit code is audited against Apple-documented patterns and mapped to OWASP MASVS categories with severity-ranked findings.
- Severity-ranked security review findings
- Corrected keychain/crypto code patterns
By the numbers
- 15 bundled reference files
- 10 most dangerous anti-patterns checklist
- iOS 13+ minimum deployment target
Files
Keychain & Security Expert Skill
Philosophy: Non-opinionated, correctness-focused. This skill provides facts, verified patterns, and Apple-documented best practices — not architecture mandates. It covers iOS 13+ as a minimum deployment target, with modern recommendations targeting iOS 17+ and forward-looking guidance through iOS 26 (post-quantum). Every code pattern is grounded in Apple documentation, DTS engineer posts (Quinn "The Eskimo!"), WWDC sessions, and OWASP MASTG — never from memory alone.
>
What this skill is: A reference for reviewing, improving, and implementing keychain operations, biometric authentication, CryptoKit cryptography, credential lifecycle management, certificate trust, and compliance mapping on Apple platforms.
>
What this skill is not: A networking guide, a server-side security reference, or an App Transport Security manual. TLS configuration, server certificate management, and backend auth architecture are out of scope except where they directly touch client-side keychain or trust APIs.
---
Decision Tree
Determine the user's intent, then follow the matching branch. If ambiguous, ask.
┌─────────────────────┐
│ What is the task? │
└─────────┬───────────┘
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌─────────┐ ┌───────────┐ ┌────────────┐
│ REVIEW │ │ IMPROVE │ │ IMPLEMENT │
│ │ │ │ │ │
│ Audit │ │ Migrate / │ │ Build from │
│ existing│ │ modernize │ │ scratch │
│ code │ │ existing │ │ │
└────┬────┘ └─────┬─────┘ └─────┬──────┘
│ │ │
▼ ▼ ▼
Run Top-Level Identify gap Identify which
Review Checklist (legacy store? domain(s) apply,
(§ below) against wrong API? load reference
the code. missing auth?) file(s), follow
Flag each item Load migration + ✅ patterns.
as ✅ / ❌ / domain-specific Implement with
⚠️ N/A. reference files. add-or-update,
For each ❌, Follow ✅ patterns, proper error
cite the verify with domain handling, and
reference file checklist. correct access
and specific control from
section. the start.---
Branch 1 — REVIEW (Audit Existing Code)
Goal: Systematically evaluate existing keychain/security code for correctness, security, and compliance.
Procedure:
1. Run the Top-Level Review Checklist (below) against the code under review. Score each item ✅ / ❌ / ⚠️ N/A. 2. For each ❌ failure, load the cited reference file and locate the specific anti-pattern or correct pattern. 3. Cross-check anti-patterns — scan code against all 10 entries in common-anti-patterns.md. Pay special attention to: UserDefaults for secrets (#1), hardcoded keys (#2), LAContext.evaluatePolicy() as sole auth gate (#3), ignored OSStatus (#4). 4. Check compliance — if the project requires OWASP MASVS or enterprise audit readiness, map findings to compliance-owasp-mapping.md categories M1, M3, M9, M10. 5. Report format: For each finding, state: what's wrong → which reference file covers it → the ✅ correct pattern → severity (CRITICAL / HIGH / MEDIUM).
Key reference files for review:
- Start with:
common-anti-patterns.md(backbone — covers 10 most dangerous patterns) - Then domain-specific files based on what the code does
- Finish with:
compliance-owasp-mapping.md(if compliance is relevant)
---
Branch 2 — IMPROVE (Migrate / Modernize)
Goal: Upgrade existing code from insecure storage, deprecated APIs, or legacy patterns to current best practices.
Procedure:
1. Identify the migration type:
- Insecure storage → Keychain: Load
migration-legacy-stores.md+credential-storage-patterns.md - Legacy Security framework → CryptoKit: Load
cryptokit-symmetric.mdorcryptokit-public-key.md+migration-legacy-stores.md - RSA → Elliptic Curve: Load
cryptokit-public-key.md(RSA migration section) - GenericPassword → InternetPassword (AutoFill): Load
keychain-item-classes.md(migration section) - LAContext-only → Keychain-bound biometrics: Load
biometric-authentication.md - File-based keychain → Data protection keychain (macOS): Load
keychain-fundamentals.md(TN3137 section) - Single app → Shared keychain (extensions): Load
keychain-sharing.md - Leaf pinning → SPKI/CA pinning: Load
certificate-trust.md
2. Follow the migration pattern in the relevant reference file. Every migration section includes: pre-migration validation, atomic migration step, legacy data secure deletion, post-migration verification.
3. Run the domain-specific checklist from the reference file after migration completes.
4. Verify no regressions using guidance from testing-security-code.md.
---
Branch 3 — IMPLEMENT (Build from Scratch)
Goal: Build new keychain/security functionality correctly from the start.
Procedure:
1. Identify which domain(s) the task touches. Use the Domain Selection Guide below. 2. Load the relevant reference file(s). Follow ✅ code patterns — never deviate from them for the core security logic. 3. Apply Core Guidelines (below) to every implementation. 4. Run the domain-specific checklist before considering the implementation complete. 5. Add tests following testing-security-code.md — protocol-based abstraction for unit tests, real keychain for integration tests on device.
Domain Selection Guide:
| If the task involves… | Load these reference files |
|---|---|
| Storing/reading a password or token | keychain-fundamentals.md + credential-storage-patterns.md |
Choosing which kSecClass to use | keychain-item-classes.md |
| Setting when items are accessible | keychain-access-control.md |
| Face ID / Touch ID gating | biometric-authentication.md + keychain-access-control.md |
| Hardware-backed keys | secure-enclave.md |
| Encrypting / hashing data | cryptokit-symmetric.md |
| Signing / key exchange / HPKE | cryptokit-public-key.md |
| OAuth tokens / API keys / logout | credential-storage-patterns.md |
| Sharing between app and extension | keychain-sharing.md |
| TLS pinning / client certificates | certificate-trust.md |
| Replacing UserDefaults / plist secrets | migration-legacy-stores.md |
| Writing tests for security code | testing-security-code.md |
| Enterprise audit / OWASP compliance | compliance-owasp-mapping.md |
---
Core Guidelines
These seven rules are non-negotiable. Every keychain/security implementation must satisfy all of them.
1. Never ignore `OSStatus`. Every SecItem* call returns an OSStatus. Use an exhaustive switch covering at minimum: errSecSuccess, errSecDuplicateItem (-25299), errSecItemNotFound (-25300), errSecInteractionNotAllowed (-25308). Silently discarding the return value is the root cause of most keychain bugs. → keychain-fundamentals.md
2. Never use `LAContext.evaluatePolicy()` as a standalone auth gate. This returns a Bool that is trivially patchable at runtime via Frida. Biometric authentication must be keychain-bound: store the secret behind SecAccessControl with .biometryCurrentSet, then let the keychain prompt for Face ID/Touch ID during SecItemCopyMatching. The keychain handles authentication in the Secure Enclave — there is no Bool to patch. → biometric-authentication.md
3. Never store secrets in `UserDefaults`, `Info.plist`, `.xcconfig`, or `NSCoding` archives. These produce plaintext artifacts readable from unencrypted backups. The Keychain is the only Apple-sanctioned store for credentials. → credential-storage-patterns.md, common-anti-patterns.md
*4. Never call `SecItem on @MainActor.** Every keychain call is an IPC round-trip to securityd that blocks the calling thread. Use a dedicated actor (iOS 17+) or serial DispatchQueue (iOS 13–16) for all keychain access. → keychain-fundamentals.md`
5. Always set `kSecAttrAccessible` explicitly. The system default (kSecAttrAccessibleWhenUnlocked) breaks all background operations and may not match your threat model. Choose the most restrictive class that satisfies your access pattern. For background tasks: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. For highest sensitivity: kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly. → keychain-access-control.md
6. Always use the add-or-update pattern. SecItemAdd followed by SecItemUpdate on errSecDuplicateItem. Never delete-then-add (creates a race window and destroys persistent references). Never call SecItemAdd without handling the duplicate case. → keychain-fundamentals.md
7. Always target the data protection keychain on macOS. Set kSecUseDataProtectionKeychain: true for every SecItem* call on macOS targets. Without it, queries silently route to the legacy file-based keychain which has different behavior, ignores unsupported attributes, and cannot use biometric protection or Secure Enclave keys. Mac Catalyst and iOS-on-Mac do this automatically. → keychain-fundamentals.md
---
Quick Reference Tables
Accessibility Constants — Selection Guide
| Constant | When Decryptable | Survives Backup | Survives Device Migration | Background Safe | Use When |
|---|---|---|---|---|---|
WhenPasscodeSetThisDeviceOnly | Unlocked + passcode set | ❌ | ❌ | ❌ | Highest-security secrets; removed if passcode removed |
WhenUnlockedThisDeviceOnly | Unlocked | ❌ | ❌ | ❌ | Device-bound secrets not needed in background |
WhenUnlocked | Unlocked | ✅ | ✅ | ❌ | Syncable secrets (system default — avoid implicit use) |
AfterFirstUnlockThisDeviceOnly | After first unlock → restart | ❌ | ❌ | ✅ | Background tasks, push handlers, device-bound |
AfterFirstUnlock | After first unlock → restart | ✅ | ✅ | ✅ | Background tasks that must survive restore |
Deprecated (never use): kSecAttrAccessibleAlways, kSecAttrAccessibleAlwaysThisDeviceOnly — deprecated iOS 12.
Rule of thumb: Need background access (push handlers, background refresh)? Start with AfterFirstUnlockThisDeviceOnly. Foreground-only? Start with WhenUnlockedThisDeviceOnly. Tighten to WhenPasscodeSetThisDeviceOnly for high-value secrets. Use non-ThisDeviceOnly variants only when iCloud sync or backup migration is required.
CryptoKit Algorithm Selection
| Need | Algorithm | Min iOS | Notes |
|---|---|---|---|
| Hash data | SHA256 / SHA384 / SHA512 | 13 | SHA3_256/SHA3_512 available iOS 18+ |
| Authenticate data (MAC) | HMAC<SHA256> | 13 | Always verify with constant-time comparison (built-in) |
| Encrypt data (authenticated) | AES.GCM | 13 | 256-bit key, 96-bit nonce, 128-bit tag. Never reuse nonce with same key |
| Encrypt data (mobile-optimized) | ChaChaPoly | 13 | Better on devices without AES-NI (older Apple Watch) |
| Sign data | P256.Signing / Curve25519.Signing | 13 | Use P256 for interop, Curve25519 for performance |
| Key agreement | P256.KeyAgreement / Curve25519.KeyAgreement | 13 | Always derive symmetric key via HKDF — never use raw shared secret |
| Hybrid public-key encryption | HPKE | 17 | Replaces manual ECDH+HKDF+AES-GCM chains |
| Hardware-backed signing | SecureEnclave.P256.Signing | 13 | P256 only; key never leaves hardware |
| Post-quantum key exchange | MLKEM768 | 26 | Formal verification (ML-KEM FIPS 203) |
| Post-quantum signing | MLDSA65 | 26 | Formal verification (ML-DSA FIPS 204) |
| Password → key derivation | PBKDF2 (via CommonCrypto) | 13 | ≥600,000 iterations SHA-256 (OWASP 2024) |
| Key → key derivation | HKDF<SHA256> | 13 | Extract-then-expand; always use info parameter for domain separation |
Anti-Pattern Detection — Quick Scan
When reviewing code, search for these patterns. Any match is a finding. ❌ = insecure pattern signature to detect in user code. ✅ = apply the corrective pattern in the referenced file.
| Search For | Anti-Pattern | Severity | Reference |
|---|---|---|---|
UserDefaults.standard.set + token/key/secret/password | Plaintext credential storage | CRITICAL | common-anti-patterns.md #1 |
| Hardcoded base64/hex strings (≥16 chars) in source | Hardcoded cryptographic key | CRITICAL | common-anti-patterns.md #2 |
evaluatePolicy without SecItemCopyMatching nearby | LAContext-only biometric gate | CRITICAL | common-anti-patterns.md #3 |
SecItemAdd without checking return / OSStatus | Ignored error code | HIGH | common-anti-patterns.md #4 |
No kSecAttrAccessible in add dictionary | Implicit accessibility class | HIGH | common-anti-patterns.md #5 |
AES.GCM.Nonce() inside a loop with same key | Potential nonce reuse | CRITICAL | common-anti-patterns.md #6 |
sharedSecret.withUnsafeBytes without HKDF | Raw shared secret as key | HIGH | common-anti-patterns.md #7 |
kSecAttrAccessibleAlways | Deprecated accessibility | HIGH | keychain-access-control.md |
SecureEnclave.isAvailable without #if !targetEnvironment(simulator) | Simulator false-negative trap | MEDIUM | secure-enclave.md |
kSecAttrSynchronizable: true + ThisDeviceOnly | Contradictory constraints | MEDIUM | keychain-item-classes.md |
SecTrustEvaluate (sync, deprecated) | Legacy trust evaluation | MEDIUM | certificate-trust.md |
kSecClassGenericPassword + kSecAttrServer | Wrong class for web credentials | MEDIUM | keychain-item-classes.md |
---
Top-Level Review Checklist
Use this checklist for a rapid sweep across all 14 domains. Each item maps to one or more reference files for deep-dive investigation. For domain-specific deep checks, use the Summary Checklist at the bottom of each reference file.
- [ ] 1. Secrets are in Keychain, not UserDefaults/plist/source — No credentials, tokens, or cryptographic keys in
UserDefaults,Info.plist,.xcconfig, hardcoded strings, orNSCodingarchives. OWASP M9 (Insecure Data Storage) directly violated. →common-anti-patterns.md#1–2,credential-storage-patterns.md,migration-legacy-stores.md,compliance-owasp-mapping.md
- [ ] 2. Every `OSStatus` is checked — All
SecItem*calls handle return codes with exhaustiveswitchor equivalent. No ignored returns.errSecInteractionNotAllowedis handled non-destructively (retry later, never delete). →keychain-fundamentals.md,common-anti-patterns.md#4
- [ ] 3. Biometric auth is keychain-bound — If biometrics are used, authentication is enforced via
SecAccessControl+ keychain access, notLAContext.evaluatePolicy()alone. →biometric-authentication.md,common-anti-patterns.md#3
- [ ] 4. Accessibility classes are explicit and correct — Every keychain item has an explicit
kSecAttrAccessiblevalue matching its access pattern (background vs foreground, device-bound vs syncable). No deprecatedAlwaysconstants. →keychain-access-control.md
- [ ] *5. No `SecItem
calls on@MainActor** — All keychain operations run on a dedicatedactoror background queue. No synchronous keychain access in UI code,viewDidLoad, orapplication(_:didFinishLaunchingWithOptions:). →keychain-fundamentals.md`
- [ ] 6. Correct `kSecClass` for each item type — Web credentials use
InternetPassword(not GenericPassword) for AutoFill. Cryptographic keys usekSecClassKeywith properkSecAttrKeyType. App secrets useGenericPasswordwithkSecAttrService+kSecAttrAccount. →keychain-item-classes.md
- [ ] 7. CryptoKit used correctly — Nonces never reused with the same key. ECDH shared secrets always derived through
HKDFbefore use as symmetric keys.SymmetricKeymaterial stored in Keychain, not in memory or files. Crypto operations covered by protocol-based unit tests. →cryptokit-symmetric.md,cryptokit-public-key.md,testing-security-code.md
- [ ] 8. Secure Enclave constraints respected — SE keys are P256 only (classical), never imported (always generated on-device), device-bound (no backup/sync). Availability checks guard against simulator and keychain-access-groups entitlement issues. →
secure-enclave.md
- [ ] 9. Sharing and access groups configured correctly —
kSecAttrAccessGroupuses fullTEAMID.group.identifierformat. Entitlements match between app and extensions. No accidental cross-app data exposure. →keychain-sharing.md
- [ ] 10. Certificate trust evaluation is current — Uses
SecTrustEvaluateAsyncWithError(not deprecated synchronousSecTrustEvaluate). Pinning strategy uses SPKI hash orNSPinnedDomains(not leaf certificate pinning which breaks on annual rotation). →certificate-trust.md
- [ ] 11. macOS targets data protection keychain — All macOS
SecItem*calls includekSecUseDataProtectionKeychain: true(except Mac Catalyst / iOS-on-Mac where it's automatic). →keychain-fundamentals.md
---
References Index
| # | File | One-Line Description | Risk |
|---|---|---|---|
| 1 | keychain-fundamentals.md | SecItem\* CRUD, query dictionaries, OSStatus handling, actor-based wrappers, macOS TN3137 routing | CRITICAL |
| 2 | keychain-item-classes.md | Five kSecClass types, composite primary keys, GenericPassword vs InternetPassword, ApplicationTag vs ApplicationLabel | HIGH |
| 3 | keychain-access-control.md | Seven accessibility constants, SecAccessControl flags, data protection tiers, NSFileProtection sidebar | CRITICAL |
| 4 | biometric-authentication.md | Keychain-bound biometrics, LAContext bypass vulnerability, enrollment change detection, fallback chains | CRITICAL |
| 5 | secure-enclave.md | Hardware-backed P256 keys, CryptoKit SecureEnclave module, persistence, simulator traps, iOS 26 post-quantum | HIGH |
| 6 | cryptokit-symmetric.md | SHA-2/3 hashing, HMAC, AES-GCM/ChaChaPoly encryption, SymmetricKey management, nonce handling, HKDF/PBKDF2 | HIGH |
| 7 | cryptokit-public-key.md | ECDSA signing, ECDH key agreement, HPKE (iOS 17+), ML-KEM/ML-DSA post-quantum (iOS 26+), curve selection | HIGH |
| 8 | credential-storage-patterns.md | OAuth2/OIDC token lifecycle, API key storage, refresh token rotation, runtime secrets, logout cleanup | CRITICAL |
| 9 | keychain-sharing.md | Access groups, Team ID prefixes, app extensions, Keychain Sharing vs App Groups entitlements, iCloud sync | MEDIUM |
| 10 | certificate-trust.md | SecTrust evaluation, SPKI/CA/leaf pinning, NSPinnedDomains, client certificates (mTLS), trust policies | HIGH |
| 11 | migration-legacy-stores.md | UserDefaults/plist/NSCoding → Keychain migration, secure deletion, first-launch cleanup, versioned migration | MEDIUM |
| 12 | common-anti-patterns.md | Top 10 AI-generated security mistakes with ❌/✅ code pairs, detection heuristics, OWASP mapping | CRITICAL |
| 13 | testing-security-code.md | Protocol-based mocking, simulator vs device differences, CI/CD keychain, Swift Testing, mutation testing | MEDIUM |
| 14 | compliance-owasp-mapping.md | OWASP Mobile Top 10 (2024), MASVS v2.1.0, MASTG test IDs, M1/M3/M9/M10 mapping, audit readiness | MEDIUM |
---
Authoritative Sources
These are the primary sources underpinning all reference files. When in doubt, defer to these over any secondary source.
- Apple Keychain Services Documentation — canonical API reference
- Apple Platform Security Guide (updated annually) — architecture and encryption design
- TN3137: "On Mac Keychain APIs and Implementations" — macOS data protection vs file-based keychain
- Quinn "The Eskimo!" DTS Posts — "SecItem: Fundamentals" and "SecItem: Pitfalls and Best Practices" (updated through 2025)
- WWDC 2019 Session 709 — "Cryptography and Your Apps" (CryptoKit introduction)
- WWDC 2025 Session 314 — "Get ahead with quantum-secure cryptography" (ML-KEM, ML-DSA)
- OWASP Mobile Top 10 (2024) + MASVS v2.1.0 + MASTG v2 — compliance framework
- CISA/FBI "Product Security Bad Practices" v2.0 (January 2025) — hardcoded credentials classified as national security risk
---
Agent Behavioral Rules
The sections below govern how an AI agent should behave when using this skill: what's in scope, what's out, tone calibration, common mistakes to avoid, how to select reference files, and output formatting requirements.
Scope Boundaries — Inclusions
This skill is authoritative for client-side Apple platform security across iOS, macOS, tvOS, watchOS, and visionOS:
- Keychain Services —
SecItemAdd,SecItemCopyMatching,SecItemUpdate,SecItemDelete, query dictionary construction,OSStatushandling, actor/thread isolation, the data protection keychain on macOS (TN3137) - Keychain item classes —
kSecClassGenericPassword,kSecClassInternetPassword,kSecClassKey,kSecClassCertificate,kSecClassIdentity, composite primary keys, AutoFill integration - Access control — The seven
kSecAttrAccessibleconstants,SecAccessControlCreateWithFlags, data protection tiers,NSFileProtectioncorrespondence - Biometric authentication —
LAContext+ keychain binding, the boolean gate vulnerability, enrollment change detection, fallback chains,evaluatedPolicyDomainState - Secure Enclave — CryptoKit
SecureEnclave.P256module, hardware constraints (P256-only, no import, no export, no symmetric), persistence via keychain, simulator traps, iOS 26 post-quantum (ML-KEM, ML-DSA) - CryptoKit symmetric — SHA-2/SHA-3 hashing, HMAC, AES-GCM, ChaChaPoly,
SymmetricKeylifecycle, nonce handling, HKDF, PBKDF2 - CryptoKit public-key — ECDSA signing (P256/Curve25519), ECDH key agreement, HPKE (iOS 17+), ML-KEM/ML-DSA (iOS 26+), curve selection
- Credential storage patterns — OAuth2/OIDC token lifecycle, API key storage, refresh token rotation, runtime secret fetching, logout cleanup
- Keychain sharing — Access groups, Team ID prefixes,
keychain-access-groupsvscom.apple.security.application-groupsentitlements, extensions, iCloud Keychain sync - Certificate trust —
SecTrustevaluation, SPKI/CA/leaf pinning,NSPinnedDomains, client certificates (mTLS), trust policies - Migration — UserDefaults/plist/NSCoding → Keychain migration, secure legacy deletion, first-launch cleanup, versioned migration
- Testing — Protocol-based mocking, simulator vs device differences, CI/CD keychain creation, Swift Testing patterns
- Compliance — OWASP Mobile Top 10 (2024), MASVS v2.1.0, MASTG v2 test IDs, CISA/FBI Bad Practices
Edge cases that ARE in scope: Client-side certificate loading for mTLS pinning (certificate-trust.md). Passkey/AutoFill credential storage in Keychain (keychain-item-classes.md, credential-storage-patterns.md). @AppStorage flagged as insecure storage — redirect to Keychain (common-anti-patterns.md).
Scope Boundaries — Exclusions
Do not answer the following topics using this skill. Briefly explain they are out of scope and suggest where to look.
| Topic | Why excluded | Redirect to |
|---|---|---|
| App Transport Security (ATS) | Server-side TLS policy, not client keychain | Apple's ATS documentation, Info.plist NSAppTransportSecurity reference |
| CloudKit encryption | Server-managed key hierarchy, not client CryptoKit | CloudKit documentation, CKRecord.encryptedValues |
| Network security / URLSession TLS config | Transport layer, not storage layer | Apple URL Loading System docs; this skill covers only client certificate loading for mTLS |
| Server-side auth architecture | Backend JWT issuance, OAuth provider config | OWASP ASVS (Application Security Verification Standard) |
| WebAuthn / passkeys server-side | Relying party implementation | Apple "Supporting passkeys" documentation; this skill covers client-side ASAuthorizationController only where it stores credentials in Keychain |
| Code signing / provisioning profiles | Build/distribution, not runtime security | Apple code signing documentation |
| Jailbreak detection | Runtime integrity, not cryptographic storage | OWASP MASTG MSTG-RESILIENCE category |
| SwiftUI `@AppStorage` | Wrapper over UserDefaults — out of scope except to flag it as insecure for secrets | common-anti-patterns.md #1 flags it; no deeper coverage |
| Cross-platform crypto (OpenSSL, LibSodium) | Third-party libraries, not Apple frameworks | Respective library documentation |
---
Tone Rules
This skill is non-opinionated and correctness-focused. Tone calibrates based on severity.
Default tone — advisory. Use "consider," "suggest," "one approach is," "a common pattern is" for: architecture choices (wrapper class design, actor vs DispatchQueue), algorithm selection when multiple valid options exist (P256 vs Curve25519, AES-GCM vs ChaChaPoly), accessibility class selection when the threat model is unclear, testing strategy, code organization.
Elevated tone — directive. Use "always," "never," "must" only for the seven Core Guidelines above and the 10 anti-patterns in common-anti-patterns.md. These are security invariants, not style preferences. The exhaustive list of directives:
1. Never ignore OSStatus — always check return codes from SecItem* calls. → keychain-fundamentals.md 2. Never use LAContext.evaluatePolicy() as a standalone auth gate — always bind biometrics to keychain items. → biometric-authentication.md 3. Never store secrets in UserDefaults, Info.plist, .xcconfig, or NSCoding archives. → credential-storage-patterns.md, common-anti-patterns.md 4. Never call SecItem* on @MainActor — always use a background actor or queue. → keychain-fundamentals.md 5. Always set kSecAttrAccessible explicitly on every SecItemAdd. → keychain-access-control.md 6. Always use the add-or-update pattern (SecItemAdd → SecItemUpdate on errSecDuplicateItem). → keychain-fundamentals.md 7. Always set kSecUseDataProtectionKeychain: true on macOS targets. → keychain-fundamentals.md 8. Never reuse a nonce with the same AES-GCM key. → cryptokit-symmetric.md, common-anti-patterns.md 9. Never use a raw ECDH shared secret as a symmetric key — always derive through HKDF. → cryptokit-public-key.md, common-anti-patterns.md 10. Never use Insecure.MD5 or Insecure.SHA1 for security purposes. → cryptokit-symmetric.md, common-anti-patterns.md
If a pattern is not on this list, use advisory tone. Do not escalate warnings beyond what the reference files support.
Tone when declining. When a query falls outside scope, be direct but not dismissive: "This skill covers client-side keychain and CryptoKit. For ATS configuration, Apple's NSAppTransportSecurity documentation is the right reference." State the boundary, suggest an alternative, move on.
---
Common AI Mistakes — The 10 Most Likely Incorrect Outputs
Before finalizing any output, scan for all 10. Each links to the reference file containing the correct pattern. Each entry is intentionally paired: ❌ incorrect generated behavior and ✅ corrective pattern to use instead.
Mistake #1 — Generating `LAContext.evaluatePolicy()` as the sole biometric gate. AI produces the boolean-callback pattern where evaluatePolicy returns success: Bool and the app gates access on that boolean. The boolean exists in hookable user-space memory — Frida/objection bypass it with one command. ✅ Correct pattern: Store a secret behind SecAccessControl with .biometryCurrentSet, retrieve via SecItemCopyMatching. → biometric-authentication.md
Mistake #2 — Suggesting `SecureEnclave.isAvailable` without simulator guard. AI generates if SecureEnclave.isAvailable { ... } without #if !targetEnvironment(simulator). On simulators, isAvailable returns false, silently taking the fallback path in all simulator testing. ✅ Correct pattern: Use #if targetEnvironment(simulator) to throw/return a clear error at compile time, check SecureEnclave.isAvailable only in device builds. → secure-enclave.md
Mistake #3 — Importing external keys into the Secure Enclave. AI generates SecureEnclave.P256.Signing.PrivateKey(rawRepresentation: someData). SE keys must be generated inside the hardware — there is no init(rawRepresentation:) on SE types. init(dataRepresentation:) accepts only the opaque encrypted blob from a previously created SE key. ✅ Correct pattern: Generate inside SE, persist opaque dataRepresentation to keychain, restore via init(dataRepresentation:). → secure-enclave.md
Mistake #4 — Using `SecureEnclave.AES` or SE for symmetric encryption. AI generates references to non-existent SE symmetric APIs. The SE's internal AES engine is not exposed as a developer API. Pre-iOS 26, the SE supports only P256 signing and key agreement. iOS 26 adds ML-KEM and ML-DSA, not symmetric primitives. ✅ Correct pattern: Use SE for signing/key agreement; derive a SymmetricKey via ECDH + HKDF for encryption. → secure-enclave.md, cryptokit-symmetric.md
Mistake #5 — Omitting `kSecAttrAccessible` in `SecItemAdd`. AI builds add dictionaries without an accessibility attribute. The system applies kSecAttrAccessibleWhenUnlocked by default, which breaks background operations and makes security policy invisible in code review. ✅ Correct pattern: Always set kSecAttrAccessible explicitly. → keychain-access-control.md
Mistake #6 — Using `SecItemAdd` without handling `errSecDuplicateItem`. AI checks only for errSecSuccess, or uses delete-then-add. Without duplicate handling, the second save silently fails. Delete-then-add creates a race window and destroys persistent references. ✅ Correct pattern: Add-or-update pattern. → keychain-fundamentals.md
Mistake #7 — Specifying explicit nonces for AES-GCM encryption. AI creates a nonce manually and passes it to AES.GCM.seal. Manual nonce management invites reuse — a single reuse reveals the XOR of both plaintexts. CryptoKit generates a cryptographically random nonce automatically when you omit the parameter. ✅ Correct pattern: Call AES.GCM.seal(plaintext, using: key) without a nonce: parameter. → cryptokit-symmetric.md, common-anti-patterns.md #6
Mistake #8 — Using raw ECDH shared secret as a symmetric key. AI takes the output of sharedSecretFromKeyAgreement and uses it directly via withUnsafeBytes. Raw shared secrets have non-uniform distribution. CryptoKit's SharedSecret deliberately has no withUnsafeBytes — this code requires an unsafe workaround, which is a clear signal of misuse. ✅ Correct pattern: Always derive via sharedSecret.hkdfDerivedSymmetricKey(...). → cryptokit-public-key.md, common-anti-patterns.md #7
Mistake #9 — Claiming SHA-3 requires iOS 26. AI conflates the post-quantum WWDC 2025 additions with the SHA-3 additions from 2024. SHA-3 family types were added in iOS 18 / macOS 15. iOS 26 introduced ML-KEM and ML-DSA, not SHA-3. ✅ Correct version tags: SHA-3 → iOS 18+. ML-KEM/ML-DSA → iOS 26+. → cryptokit-symmetric.md
Mistake #10 — Missing first-launch keychain cleanup. AI generates a standard @main struct MyApp: App without keychain cleanup. Keychain items survive app uninstallation. A reinstalled app inherits stale tokens, expired keys, and orphaned credentials. ✅ Correct pattern: Check a UserDefaults flag, SecItemDelete across all five kSecClass types on first launch. → common-anti-patterns.md #9, migration-legacy-stores.md
---
Reference File Loading Rules
Load the minimum set of files needed to answer the query. Do not load all 14 — they total ~7,000+ lines and will dilute focus.
| Query type | Load these files | Reason |
|---|---|---|
| "Review my keychain code" | common-anti-patterns.md → then domain-specific files based on what the code does | Anti-patterns file is the review backbone |
| "Is this biometric auth secure?" | biometric-authentication.md + common-anti-patterns.md (#3) | Boolean gate is the #1 biometric risk |
| "Store a token / password" | keychain-fundamentals.md + credential-storage-patterns.md | CRUD + lifecycle |
| "Encrypt / hash data" | cryptokit-symmetric.md | Symmetric operations |
| "Sign data / key exchange" | cryptokit-public-key.md | Asymmetric operations |
| "Use Secure Enclave" | secure-enclave.md + keychain-fundamentals.md | SE keys need keychain persistence |
| "Share keychain with extension" | keychain-sharing.md + keychain-fundamentals.md | Access groups + CRUD |
| "Migrate from UserDefaults" | migration-legacy-stores.md + credential-storage-patterns.md | Migration + target patterns |
| "TLS pinning / mTLS" | certificate-trust.md | Trust evaluation |
| "Which kSecClass?" | keychain-item-classes.md | Class selection + primary keys |
| "Set up data protection" | keychain-access-control.md | Accessibility constants |
| "Write tests for keychain code" | testing-security-code.md | Protocol mocks + CI/CD |
| "OWASP compliance audit" | compliance-owasp-mapping.md + common-anti-patterns.md | Mapping + detection |
| "Full security review" | common-anti-patterns.md + all files touched by the code | Start with anti-patterns, expand |
Loading order: (1) Most specific file for the query. (2) Add common-anti-patterns.md for any review/audit. (3) Add keychain-fundamentals.md for any SecItem* task. (4) Add compliance-owasp-mapping.md only if OWASP/audit is mentioned. (5) Never load files speculatively.
---
Output Format Rules
1. Always include ✅/❌ code examples. Show both the incorrect/insecure version and the correct/secure version. Exception: pure informational queries ("what accessibility constants exist?") do not need ❌ examples.
2. Always cite iOS version requirements. Every API recommendation must include the minimum iOS version inline: "Use HPKE (iOS 17+) for hybrid public-key encryption."
3. Always cite the reference file. When referencing a pattern or anti-pattern, name the source: "See biometric-authentication.md for the full keychain-bound pattern."
4. Always include `OSStatus` handling in keychain code. Never output bare SecItemAdd / SecItemCopyMatching calls without error handling. At minimum: errSecSuccess, errSecDuplicateItem (for add), errSecItemNotFound (for read), errSecInteractionNotAllowed (non-destructive retry).
5. Always specify `kSecAttrAccessible` in add examples. Every SecItemAdd code example must include an explicit accessibility constant.
6. State severity for findings. CRITICAL = exploitable vulnerability. HIGH = silent data loss or wrong security boundary. MEDIUM = suboptimal but not immediately exploitable.
7. Prefer modern APIs with fallback notes. Default to iOS 17+ (actor-based). Note fallbacks: iOS 15–16 (serial DispatchQueue + async/await bridge), iOS 13–14 (completion handlers).
8. Never fabricate citations or WWDC session numbers. If a session/reference is not in the loaded references, say it is unverified and avoid inventing identifiers.
9. Implementation and improvement responses must conclude with a `## Reference Files` section. List every reference file that informed the response with a one-line note on what it contributed. This applies to all response types — code generation, migration guides, and improvements — not just reviews. Example: - \keychain-fundamentals.md\ — SecItem CRUD and error handling.
10. Cite SKILL.md structural sections when they govern the response. When declining an out-of-scope query, reference "Scope Boundaries — Exclusions." When using advisory vs directive tone on an opinion-seeking question, reference "Tone Rules." When a version constraint shapes the answer, reference "Version Baseline Quick Reference." A brief parenthetical is sufficient — e.g., "(per Scope Boundaries — Exclusions)."
---
Behavioral Boundaries
Things the agent must do:
- Ground every code pattern in the reference files. If a pattern is not documented, say so and suggest verifying against Apple documentation.
- Flag when code is simulator-only tested. Simulator behavior differs for Secure Enclave, keychain, and biometrics.
- Distinguish compile-time vs runtime errors. SE key import = compile-time. Missing accessibility class = runtime (silent wrong default). Missing OSStatus check = runtime (lost error).
Things the agent must not do:
- Do not invent WWDC session numbers. Only cite sessions documented in the reference files.
- ✅ examples must always use native APIs — never third-party library code (KeychainAccess, SAMKeychain, Valet). When a user explicitly asks to compare native APIs with a third-party library, adopt advisory tone: present objective tradeoffs without directive rejection. Model: _"Native APIs have no dependency overhead; KeychainAccess and Valet reduce boilerplate at the cost of coupling to a third-party maintenance schedule."_ Do not say "This skill does not recommend..." — that is directive output outside the Core Guidelines.
- Do not claim Apple APIs are buggy without evidence. Guide debugging (query dictionary errors, missing entitlements, wrong keychain) before suggesting API defects.
- Do not generate Security framework code when CryptoKit covers the use case (iOS 13+).
- Do not output partial keychain operations. Never show
SecItemAddwithouterrSecDuplicateItemfallback. Never showSecItemCopyMatchingwithouterrSecItemNotFoundhandling. - Do not escalate tone beyond what the reference files support.
---
Cross-Reference Protocol
- Canonical source: Each pattern has one primary reference file (per the References Index above).
- Brief mention + redirect elsewhere: Other files get a one-sentence summary, not the full code example.
- Agent behavior: Cite the canonical file. Load it for detail. Do not reconstruct patterns from secondary mentions.
---
Version Baseline Quick Reference
| API / Feature | Minimum iOS | Common AI mistake |
|---|---|---|
| CryptoKit (SHA-2, AES-GCM, P256, ECDH) | 13 | Claiming iOS 15+ |
SecureEnclave.P256 (CryptoKit) | 13 | Claiming iOS 15+ |
SHA-3 (SHA3_256, SHA3_384, SHA3_512) | 18 | Claiming iOS 26+ |
HPKE (HPKE.Sender, HPKE.Recipient) | 17 | Claiming iOS 15+ or iOS 18+ |
| ML-KEM / ML-DSA (post-quantum) | 26 | Conflating with SHA-3 |
SecAccessControl with .biometryCurrentSet | 11.3 | Claiming iOS 13+ |
kSecUseDataProtectionKeychain (macOS) | macOS 10.15 | Omitting entirely on macOS |
Swift concurrency actor | 13 (runtime), 17+ (recommended) | Claiming iOS 15 minimum |
LAContext.evaluatedPolicyDomainState | 9 | Not knowing it exists |
NSPinnedDomains (declarative pinning) | 14 | Claiming iOS 16+ |
---
Agent Self-Review Checklist
Run before finalizing any response that includes security code:
- [ ] Every
SecItemAddhas an explicitkSecAttrAccessiblevalue - [ ] Every
SecItemAddhandleserrSecDuplicateItemwithSecItemUpdatefallback - [ ] Every
SecItemCopyMatchinghandleserrSecItemNotFound - [ ] No
LAContext.evaluatePolicy()used as standalone auth gate - [ ] No
SecItem*calls on@MainActoror main thread - [ ] macOS code includes
kSecUseDataProtectionKeychain: true - [ ] Secure Enclave code has
#if targetEnvironment(simulator)guard - [ ] No raw ECDH shared secret used as symmetric key
- [ ] No explicit nonce in
AES.GCM.sealunless the user has a documented reason - [ ] iOS version tags are present for every API recommendation
- [ ] Reference file is cited for every pattern shown
- [ ] Severity is stated for every finding (review/audit tasks)
- [ ] No fabricated WWDC session numbers
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.---
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()
// Delete any existing item first (add-or-update pattern)
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: service
]
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: service,
kSecValueData as String: secret,
kSecAttrAccessControl as String: accessControl,
kSecAttrSynchronizable as String: kCFBooleanFalse // Never sync biometric-gated secrets
// NOTE: Do NOT set kSecAttrAccessible — it conflicts with kSecAttrAccessControl
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
guard status == errSecSuccess else {
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.
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.evaluationPolicyDomainState (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+ through iOS 18, macOS 10.14+ through macOS 15.
>
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).
---
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)))
}
}
}Apple has not added native async/await wrappers to the Security framework through iOS 18. Wrap manually:
// ✅ 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.
⚠️ Cross-validation note: The parallel research source omits the ASN.1 header prepend step and uses deprecated SecTrustGetCertificateAtIndex. The code below uses the correct modern APIs with proper SPKI construction.// ✅ 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; no new SecTrust APIs |
---
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-Validation Notes
Both research sources agree on all major recommendations. Key discrepancies in the parallel source (corrected in this file):
1. Deprecated API in code example: Parallel source uses SecTrustGetCertificateAtIndex(trust, 0) — deprecated iOS 15. Corrected to SecTrustCopyCertificateChain. 2. Missing ASN.1 header: Parallel source hashes raw key bytes without prepending the SPKI ASN.1 header, producing incorrect hashes. Corrected with explicit header prepend. 3. Deprecated `SecTrustCopyPublicKey` reference: Parallel source references this API — deprecated iOS 14. Corrected to SecCertificateCopyKey. 4. Main queue evaluation: Parallel source evaluates on .main queue. Corrected to background queue.
---
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
Related skills
FAQ
What platforms does swift-security-expert cover?
iOS 13+ as a minimum deployment target, with modern recommendations for iOS 17+ and forward-looking post-quantum guidance through iOS 26.
Does it cover networking or server-side security?
No. TLS configuration, server certificate management, and backend auth architecture are out of scope except where they touch client-side keychain or trust APIs.