ios-security — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited ios-security (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
iOS gives you sandboxing, code signing, and Keychain for free. The work is around them: storing the right thing in the right place, validating remote connections, and resisting the runtime tampering that happens once an app is in the hands of someone willing to jailbreak.
This skill is for native (Swift / Objective-C / SwiftUI) iOS and macOS apps. Cross-platform frameworks (React Native, Flutter) share most of the principles but have their own specifics.
Keychain is the right place for tokens, passwords, refresh tokens, and small secrets. It is not the right place for large data or files. Pick the right accessibility constant for each item.
import Security
let token = "..."
let data = token.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.MyApp",
kSecAttrAccount as String: "auth_token",
kSecValueData as String: data,
// Choose the right accessibility:
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)Accessibility constants — pick the strictest that works for your flow:
| Constant | When the item is readable |
|---|---|
kSecAttrAccessibleWhenUnlockedThisDeviceOnly | Only while unlocked, never restored to another device. Default for sensitive items. |
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly | After first unlock since boot — useful for background tasks |
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly | Only if user has a device passcode set — strongest |
kSecAttrAccessibleWhenUnlocked | Same as above but restorable to another device via backup — avoid for tokens |
kSecAttrAccessibleAlways | Deprecated — do not use |
The ThisDeviceOnly suffix prevents the item from being restored to a different device via iCloud backup. For auth tokens, you usually want this.
For high-value items, require the user to authenticate at access time:
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.biometryCurrentSet, .or, .devicePasscode],
nil
)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.MyApp",
kSecAttrAccount as String: "vault_master_key",
kSecValueData as String: secretData,
kSecAttrAccessControl as String: access,
].biometryCurrentSet invalidates the item if the user adds/removes a biometric enrollment — closes the "attacker enrolls their own fingerprint, then unlocks" hole.
ATS forces HTTPS with TLS 1.2+. Defaults are good — keep them. Do not add NSAllowsArbitraryLoads = true to Info.plist. Per-domain exceptions exist (NSExceptionDomains) but each is a written-down compromise; document why and review periodically.
For domains you control, ATS-compatible TLS is table stakes: TLS 1.2+, modern ciphers, valid cert. ATS will reject your own backend if you ship a misconfigured server (which is a feature).
Pinning hardcodes the expected server cert or public key into the app. Without pinning, an attacker with a fraudulent (CA-issued or device-installed) cert can MITM the app. With pinning, the app rejects anything not matching the pin.
Pros: stops MITM via rogue CAs, stops corporate-proxy interception, stops user-installed root CAs.
Cons: if your server cert expires or rotates and you didn't ship an update first, every pinned app version is bricked until users update. Pin rotation is operationally painful.
Pinning is high-leverage for banking, payments, identity, and similar. Lower-stakes apps often don't pin and that's a reasonable choice.
// Sketch — URLSession delegate pinning to a public-key hash
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil); return
}
// Pin to public-key SHA-256 of leaf or intermediate
if isPinned(serverTrust) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}Pin to the intermediate or to a primary+backup leaf key, not a single leaf. Ship multiple pins so cert rotation is graceful. Have a "kill pin" remote-config switch to disable pinning in an emergency (with logging).
iOS encrypts disk at rest, but the encryption-key availability depends on the Data Protection class per file. For app-created files, set the class explicitly:
let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("sensitive.json")
let data: Data = ...
try data.write(to: url, options: [.atomic, .completeFileProtection])
// Equivalent to NSFileProtectionComplete — readable only when device is unlockedProtection classes:
.complete (NSFileProtectionComplete) — readable only while device is unlocked. Default for new files in most app templates..completeUnlessOpen — file stays readable through a lock if open; otherwise like .complete.completeUntilFirstUserAuthentication — readable after first unlock since boot (good for background tasks).none — readable as soon as the device boots — avoid for anything sensitiveVerify by inspecting on a device — there is no compile-time check that you set the right class.
Jailbreak detection exists, but on a determined adversary's device it can always be bypassed (Frida, Liberty Lite, dynamic-library injection). Use it as signal, not as defense:
Common heuristics (none individually conclusive):
/Applications/Cydia.app, /private/var/lib/apt/, /usr/sbin/sshdDYLD_INSERT_LIBRARIES)Combine 3+ checks and treat as a risk signal, not a binary verdict.
Custom URL schemes (myapp://) are not authenticated — any app can claim them. Use Universal Links (https://example.com/... opens your app) for security-relevant deep links.
For OAuth callbacks specifically, Apple recommends ASWebAuthenticationSession over custom URL schemes. It runs in an ephemeral, isolated browser context and the result is delivered only to the requesting app.
If you use App Groups to share data between your main app and extensions (Share, Widget, etc.), remember that anything in the App Group is visible to every member of the group. Don't share Keychain items more broadly than needed via kSecAttrAccessGroup.
Extensions also have their own attack surface — they receive content from arbitrary apps (Share extension), so validate inputs and don't trust the metadata.
Validate receipts server-side, not in the app. The local receipt format is well-known and forgeable on a jailbroken device. Apple provides the receipt validation endpoint specifically for backend use.
For TestFlight / enterprise / Mac apps shipped outside the App Store:
App Store and TestFlight handle this for you; for everything else, you build it.
Every SDK you embed runs with the app's privileges. Before adopting:
Info.plistSame threat model as a backend dependency — see dependency-supply-chain.
iOS requires usage strings for each privacy-sensitive API. Wrong or missing strings cause rejections and confused users.
<key>NSCameraUsageDescription</key>
<string>This lets you scan QR codes for...</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We use location to show nearby...</string>Plus the App Store Privacy "Nutrition Label" must match what the app actually does. Lying here is App Store policy violation territory, plus a regulatory issue under GDPR if you serve EU users.
ThisDeviceOnly accessibility constantsSecAccessControl with biometric/passcode gateNSAllowsArbitraryLoads; per-domain ATS exceptions documented.completeFileProtection where appropriatestrings YourApp.app/YourApp)ASWebAuthenticationSession, not custom URL scheme handlers~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.