Store tokens in the Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly and write sensitive files with complete file protection. Leave App Transport Security on and scope any exception to one host. Prefer universal links to custom URL schemes and validate every URL. In WKWebView, act only on script messages from your main frame and origin. Gate step-up on a Secure Enclave key that needs biometrics, verify App Attest on your server, and declare required-reason APIs in PrivacyInfo.xcprivacy.
- The default Keychain class,
WhenUnlocked, moves to a new device in an encrypted backup;ThisDeviceOnlyclasses don't. - App files default to Class C, readable from first unlock until restart. Use complete protection for files you only read in the foreground.
- Keychain items can survive deleting the app, so clear them on the first launch after an install.
- WebKit matches
WKAppBoundDomainsentries by registrable domain: listinghelp.bank.example.comalso admitsexample.com. - An
LAContextBoolean proves nothing to your server; a signature from a biometry-bound Secure Enclave key does. - App Attest keys don't survive a reinstall or restore, and
isSupportedis false in the Simulator. Verify everything on the server.
| Setting | Value | Note | Source |
|---|---|---|---|
| Keychain token | kSecAttrAccessibleWhenUnlockedThisDeviceOnly | Use AfterFirstUnlockThisDeviceOnly only if a background task must read it. kSecAttrAccessibleAlways has been deprecated since iOS 12. | Apple: Restricting keychain item accessibility |
| High-value Keychain item or key | SecAccessControl with .biometryCurrentSet (or .userPresence to allow the passcode) | biometryCurrentSet invalidates the item when Face ID or Touch ID enrollment changes. | SecAccessControlCreateFlags |
| File protection | .completeFileProtection for files used in the foreground | completeUnlessOpen for background writes. The default for app files is completeUntilFirstUserAuthentication. | Apple Platform Security: Data Protection classes |
| Backups | isExcludedFromBackup = true on re-downloadable caches | Not on user documents, where common edits can reset it. | URLResourceValues.isExcludedFromBackup |
NSAppTransportSecurity | No NSAllowsArbitraryLoads; exceptions per domain only | Exceptions that weaken ATS need a justification in App Review. | Apple: Preventing insecure network connections |
NSPinnedDomains | SPKI SHA-256 of the issuing CA plus a backup | Only if you can rotate. NSIncludesSubdomains covers one level of subdomain. | Apple: Identity pinning |
| Associated Domains | applinks:bank.example.com and webcredentials:auth.bank.example.com | Serve /.well-known/apple-app-site-association over HTTPS with no redirects. | Apple: Supporting associated domains |
WKWebView | limitsNavigationsToAppBoundDomains = true; allowsContentJavaScript = false for static pages | WKAppBoundDomains takes up to 10 entries. isInspectable is false by default. | WebKit: App-Bound Domains |
| Step-up key | SecureEnclave.P256.Signing with [.privateKeyUsage, .biometryCurrentSet] | The key can't be exported; its handle works only on the device that made it. | Apple: Protecting keys with the Secure Enclave |
| App Attest Environment entitlement | development while developing | TestFlight, App Store and Enterprise builds use production regardless of the setting. | Apple: App Attest Environment |
| Logging | Logger with the default privacy for dynamic values | Dynamic strings are redacted unless marked public; print() and NSLog() aren't. | Apple: Generating log messages from your code |
| Build and deployment minimums | Xcode 26 and the iOS 26 SDK; deployment target iOS 13 or later | Enforced by App Store Connect since 28 April and 9 September 2026. | Apple Developer: Upcoming requirements |
- Inspect the bundle you submit:
plutil -ponInfo.plistfor ATS and app-bound domain keys, andcodesign -d --entitlements -for associated domains, keychain groups and the App Attest environment. - Run
nscurl --ats-diagnosticsagainst each API host and confirm the default ATS connection passes with no exceptions. - Fetch
/.well-known/apple-app-site-associationfrom every associated domain and confirm a 200 over HTTPS with no redirect. - Open malformed universal links and custom-scheme URLs on a device and confirm nothing happens beyond a rejected or read-only screen.
- Delete and reinstall the app, then confirm it starts signed out with no tokens left in the Keychain.
- On a device with a passcode, lock it and confirm background code can't read files written with complete protection; the Simulator doesn't enforce Data Protection.
- Run the iOS project's XCTest suite in the Simulator: the token must read back from the Keychain as
kSecAttrAccessibleWhenUnlockedThisDeviceOnly, a plainhttp://request must fail withNSURLErrorAppTransportSecurityRequiresSecureConnection, and a message from an iframe or another origin must do nothing.
- Applies to
- iOS and iPadOS 17.4 to 27.0; tested in the iOS 27.0 Simulator
- Xcode and Swift Xcode 27.0 with Swift 6.4; App Store Connect requires Xcode 26 or later
- ASWebAuthenticationSession HTTPS callbacks iOS 17.4 and later
- CryptoKit ML-KEM, ML-DSA and X-Wing iOS 26 and later
- App Attest iOS 14 and later; authenticator-data extensions from iOS 27
iOS apps written in Swift for current iOS releases. Keychain and data protection behavior depends on the device passcode state and the accessibility class chosen.
- Part of
- Workflow: Review a design
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Choose Keychain accessibility classes and file protection levels for each kind of data.
- Configure App Transport Security, including narrow exceptions and, when justified, pinning.
- Close universal links, custom schemes and
WKWebViewbridges to untrusted callers. - Use Secure Enclave keys, App Attest and the privacy manifest as verifiable controls, mapped to MASVS 2.1.
Before you start
iOS developers and mobile AppSec reviewers who ship or assess Swift apps
- Read Swift and
Info.plistentries. - Know how the Keychain, entitlements and URL handling fit into an iOS app.
- Understand TLS certificate validation at a basic level.
OWASP MASVS 2.1.0 is still the current release. MASTG 2.0 (June 2026) reaches the controls through MASWE weaknesses: each test checks one weakness, and the weakness lists its controls. Not every control has a test yet. Choose the depth with the MAS testing profiles: L1 treats other apps as the adversary, L2 assumes the attacker may control the operating system or hold the phone, and R adds resistance to reverse engineering. The table lists the iOS mechanism this guide uses for each control; /domains/appsec/platform-security/android-ios-security/ pairs each with its Android equivalent, and /domains/appsec/platform-security/android-secure-coding/ covers Android in depth.
| Control | Requirement | iOS mechanism |
|---|---|---|
| MASVS-STORAGE-1 | Store sensitive data securely | Keychain with a ThisDeviceOnly class; complete file protection |
| MASVS-STORAGE-2 | Prevent leaks | Logger redaction, snapshot hiding, local expiring pasteboard, ephemeral sessions |
| MASVS-CRYPTO-1, CRYPTO-2 | Strong cryptography and key management | CryptoKit AES-GCM; non-exportable Secure Enclave keys |
| MASVS-AUTH-2, AUTH-3 | Local authentication and step-up | Biometry-bound Secure Enclave key, not an LAContext Boolean |
| MASVS-NETWORK-1 | Secure all traffic | App Transport Security with no global exceptions |
| MASVS-NETWORK-2 | Identity pinning (L2) | NSPinnedDomains with a backup key |
| MASVS-PLATFORM-1 | Secure IPC | Universal links rather than custom schemes; strict URL parsing |
| MASVS-PLATFORM-2 | Secure WebViews | WKWebView with app-bound domains and origin-checked message handlers |
| MASVS-RESILIENCE-1, -2 | Platform and app integrity | App Attest verified on the server |
| MASVS-PRIVACY-1, PRIVACY-3 | Minimize and disclose data use | PrivacyInfo.xcprivacy with required-reason APIs |
The Keychain encrypts each item with AES-256-GCM, and decrypting an item's secret needs a round trip through the Secure Enclave. The item's accessibility class decides when it can be read and whether it leaves the device. The default, kSecAttrAccessibleWhenUnlocked, moves to a new phone in an encrypted backup; classes ending in ThisDeviceOnly never do. WhenPasscodeSetThisDeviceOnly items are deleted if the user removes the passcode. Use AfterFirstUnlock only for items a background task must read, and never kSecAttrAccessibleAlways, deprecated since iOS 12.
Don't mark session tokens kSecAttrSynchronizable: synced items travel through iCloud Keychain to every device and can't use a ThisDeviceOnly class. Items can also outlive the app. They have historically survived deletion, Apple's developer support calls that an implementation detail, and they survived it in the iOS 27 Simulator. Clear them on the first launch after an install, keyed on a UserDefaults flag, because defaults are deleted with the app.
import Foundation
import Security
enum KeychainError: Error { case unexpectedStatus(OSStatus) }
struct TokenStore {
let service = "com.example.bank.session"
private func baseQuery(_ account: String) -> [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account]
}
func save(_ token: Data, account: String) throws {
let attributes: [String: Any] = [
kSecValueData as String: token,
// Only while unlocked; never synced, never restored to another device.
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]
var status = SecItemUpdate(baseQuery(account) as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
let item = baseQuery(account).merging(attributes) { $1 }
status = SecItemAdd(item as CFDictionary, nil)
}
guard status == errSecSuccess else { throw KeychainError.unexpectedStatus(status) }
}
func read(account: String) throws -> Data? {
var query = baseQuery(account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess else { throw KeychainError.unexpectedStatus(status) }
return result as? Data
}
func deleteAll() {
SecItemDelete([kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service] as CFDictionary)
}
}
// Keychain items can outlive an uninstall; UserDefaults cannot.
// Run once at launch so a reinstalled app starts signed out.
func clearKeychainAfterReinstall(_ store: TokenStore = TokenStore()) {
let defaults = UserDefaults.standard
guard !defaults.bool(forKey: "hasLaunchedBefore") else { return }
store.deleteAll()
defaults.set(true, forKey: "hasLaunchedBefore")
}Data Protection encrypts files with class keys derived from the passcode, so it only protects devices that have one. Third-party app files default to Class C, completeUntilFirstUserAuthentication: readable from the first unlock until the next restart, which stops only an attacker who reboots the phone. Give files the app reads in the foreground complete protection. Their class key is discarded shortly after lock, after 10 seconds when Require Password is set to Immediately, so background reads fail. completeUnlessOpen lets background work create a file, or keep using one it opened, while the device is locked.
To raise the default for the whole app, set the Data Protection entitlement to NSFileProtectionComplete. Exclude re-downloadable caches from backup with isExcludedFromBackup, but not user documents, where ordinary edits can reset the flag. The Simulator doesn't enforce Data Protection, so test on a device.
import Foundation
// Class A: unreadable from shortly after the device locks until the next unlock.
func saveStatement(_ pdf: Data, named name: String) throws -> URL {
let dir = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask,
appropriateFor: nil, create: true)
var url = dir.appendingPathComponent(name)
try pdf.write(to: url, options: [.atomic, .completeFileProtection])
var values = URLResourceValues()
values.isExcludedFromBackup = true // a cached copy; the server keeps the original
try url.setResourceValues(values)
return url
}Logger redacts interpolated strings and objects unless you mark them public, and a hash mask still lets you correlate one user's events. print() and NSLog() write values in clear text.
- UIKit snapshots the screen for the app switcher right after your scene enters the background. Remove sensitive content in
sceneDidEnterBackgroundand restore it when the scene becomes active. - Copy one-time codes with
.localOnly, which keeps them off other devices through Handoff, and an.expirationDate. - Load account data through an ephemeral
URLSession, which keeps caches, cookies and credentials out of persistent storage. - Turn on secure text entry for passwords and codes, and turn off autocorrection on fields that hold account numbers.
import Foundation
import UIKit
import UniformTypeIdentifiers
import os
let authLog = Logger(subsystem: "com.example.bank", category: "auth")
func didRefreshSession(userID: String) {
// Dynamic strings are redacted by default; the hash mask still lets you correlate.
authLog.info("Session refreshed for \(userID, privacy: .private(mask: .hash))")
// print() and NSLog() write the value in clear text. Never pass them tokens.
}
func copyOneTimeCode(_ code: String) {
UIPasteboard.general.setItems(
[[UTType.plainText.identifier: code]],
options: [.localOnly: true, // no Universal Clipboard
.expirationDate: Date().addingTimeInterval(60)])
}
// Responses with account data: no disk cache, no cookies left behind.
let privateSession = URLSession(configuration: .ephemeral)ATS makes the URL Loading System refuse plain HTTP and require TLS 1.2 or later, forward secrecy through ECDHE, AES-128 or AES-256, and certificates signed with SHA-256 or better on RSA 2048-bit or 256-bit ECC keys. Separately, publicly trusted server certificates must meet Apple's Certificate Transparency policy or the connection fails. ATS doesn't cover the Network framework or lower-level APIs. Since iOS 26, URLSession and the Network framework negotiate a quantum-secure TLS key exchange by default when the server supports one; the deprecated Secure Transport API doesn't.
NSAllowsArbitraryLoads turns ATS off for every host and needs a justification in App Review; on iOS 10 and later it's ignored if NSAllowsArbitraryLoadsInWebContent, NSAllowsArbitraryLoadsForMedia or NSAllowsLocalNetworking is present. If one server can't be fixed, add an exception for that domain only, and send it nothing sensitive.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<!-- Vulnerable: <key>NSAllowsArbitraryLoads</key><true/> switches ATS off for every host. -->
<!-- Fixed: keep ATS on and scope the exception to the one host that needs it. -->
<key>NSExceptionDomains</key>
<dict>
<key>rates.partner.example</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
</dict>
</plist>Apple says pinning isn't required and should be avoided in most cases, and OWASP puts it in the L2 profile. If you need it, declare it in NSPinnedDomains (iOS 14 and later) instead of writing trust-evaluation code. Pin the SPKI SHA-256 of the issuing CA plus a backup, so leaf certificates can change freely. If a domain lists both CA and leaf identities, ATS requires a match in each. NSIncludesSubdomains extends a pin one level only: to math.example.org, not advanced.math.example.org.
A mismatch fails the connection with a TLS error, so plan the user experience and the update path before you ship. Pinning doesn't stop someone who controls the device and hooks the trust check; App Attest and server-side controls cover that case.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSPinnedDomains</key>
<dict>
<key>api.bank.example.com</key>
<dict>
<!-- Issuing-CA keys: the one in use and a backup you could move to. -->
<key>NSPinnedCAIdentities</key>
<array>
<dict>
<key>SPKI-SHA256-BASE64</key>
<string>44viFzTC+h/L+3OHRg4Rs5v4+AcpzHZvI9Tne2RDNGk=</string>
</dict>
<dict>
<key>SPKI-SHA256-BASE64</key>
<string>9C7mf4J789KvLX59lcMyYpsH6bpdmoAGTByZNhcusLA=</string>
</dict>
</array>
</dict>
</dict>
</dict>
</dict>
</plist>
<!-- Compute a value from a PEM certificate:
openssl x509 -in ca.pem -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64 -->Any app can register your custom URL scheme, and when several do, iOS doesn't define which one opens. Universal links are bound to your domain: list applinks:bank.example.com in the Associated Domains entitlement and serve apple-app-site-association from /.well-known over HTTPS, with a valid certificate and no redirects. Since iOS 14, devices fetch that file through Apple's CDN, which requests it within 24 hours; installed apps check about once a week. SwiftUI delivers universal links to onOpenURL.
The association proves routing, not intent. Apple's guidance is to validate every parameter, discard malformed URLs and keep links away from actions that put user data at risk. For sign-in, use ASWebAuthenticationSession with PKCE, never a WKWebView. Since iOS 17.4 it accepts an .https(host:path:) callback, but only when that host is listed in Associated Domains with the webcredentials service.
import Foundation
enum Route: Equatable { case invoice(id: String) }
// Universal link: https://bank.example.com/invoices/<id>. Anything else is rejected.
func route(for url: URL) -> Route? {
guard let parts = URLComponents(url: url, resolvingAgainstBaseURL: false),
parts.scheme == "https",
parts.host == "bank.example.com",
parts.port == nil, parts.user == nil, parts.password == nil,
(parts.queryItems ?? []).isEmpty
else { return nil }
let segments = parts.path.split(separator: "/", omittingEmptySubsequences: false)
guard segments.count == 3, segments[0].isEmpty, segments[1] == "invoices",
segments[2].range(of: "^[A-Z0-9]{8,20}$", options: .regularExpression) != nil
else { return nil }
// Open a read-only screen. Paying still needs the normal confirmation and
// a server-side authorization check.
return .invoice(id: String(segments[2]))
}For pages you don't own, use SFSafariViewController, or ASWebAuthenticationSession for sign-in; your app can't read or script either one. The App Store has rejected UIWebView since 2020. In a WKWebView, a script message handler is reachable from every frame, so check message.frameInfo.isMainFrame and its securityOrigin before acting; for the default HTTPS port, securityOrigin.port is 0. Set allowsContentJavaScript to false for static content. isInspectable defaults to false; leave it off in release builds.
App-bound domains (iOS 14 and later) add a navigation fence. List up to 10 domains in WKAppBoundDomains and set limitsNavigationsToAppBoundDomains; that web view then can't leave them, and web views without the flag lose script injection, message handlers and cookie APIs. WebKit compares registrable domains, so an entry for help.bank.example.com also admits example.com. Keep an exact-host check in decidePolicyFor.
import UIKit
import WebKit
// Page messages are untrusted input: check the frame and origin, then map a
// fixed set of action names to native code. Never evaluate or open what the page sends.
final class HelpBridge: NSObject, WKScriptMessageHandler {
static let host = "help.bank.example.com"
private let allowedActions: Set<String> = ["openSettings", "contactSupport"]
var onAction: ((String) -> Void)?
func userContentController(_ controller: WKUserContentController,
didReceive message: WKScriptMessage) {
let origin = message.frameInfo.securityOrigin
guard message.frameInfo.isMainFrame,
origin.protocol == "https", origin.host == Self.host, origin.port == 0,
let action = (message.body as? [String: Any])?["action"] as? String,
allowedActions.contains(action)
else { return }
onAction?(action)
}
}
@MainActor
func makeHelpWebView(bridge: HelpBridge, delegate: WKNavigationDelegate) -> WKWebView {
let config = WKWebViewConfiguration()
config.limitsNavigationsToAppBoundDomains = true // list the host in WKAppBoundDomains
config.userContentController.add(bridge, name: "bridge")
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = delegate
webView.load(URLRequest(url: URL(string: "https://\(HelpBridge.host)/")!))
return webView
}
// Call from webView(_:decidePolicyFor:decisionHandler:) in the navigation delegate.
@MainActor
func policy(for action: WKNavigationAction) -> WKNavigationActionPolicy {
guard let url = action.request.url, url.scheme == "https" else { return .cancel }
if url.host == HelpBridge.host { return .allow }
if action.navigationType == .linkActivated { UIApplication.shared.open(url) } // outside the app
return .cancel
}LAContext.evaluatePolicy returns a Boolean that a patched app can fake, and it proves nothing to your server. Bind the check to a key instead. The Secure Enclave generates the key and never releases it. CryptoKit's dataRepresentation is an encrypted handle that only the same device's Secure Enclave accepts, so store it in the Keychain and register the public key with your server. With .biometryCurrentSet, every signature needs Face ID or Touch ID from the current enrollment, and enrollment changes invalidate the key. Include .privateKeyUsage or signing fails.
Through the Security framework, the Secure Enclave handles only P-256 keys. From iOS 26, CryptoKit adds ML-KEM and ML-DSA keys inside the Secure Enclave and post-quantum HPKE based on X-Wing. For data at rest, AES.GCM with a 256-bit SymmetricKey is the default choice.
import CryptoKit
import Foundation
import LocalAuthentication
import Security
// A P-256 key generated inside the Secure Enclave. Every signature needs Face ID or
// Touch ID from the biometrics enrolled when the key was made.
enum StepUpKey {
static func create() throws -> SecureEnclave.P256.Signing.PrivateKey {
var error: Unmanaged<CFError>?
guard let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet],
&error) else { throw error!.takeRetainedValue() as Error }
return try SecureEnclave.P256.Signing.PrivateKey(accessControl: access)
// Send key.publicKey.derRepresentation to the server at enrollment, and store
// key.dataRepresentation (an encrypted handle only this Secure Enclave can use)
// in the Keychain.
}
// Sign the server's one-time challenge; the server verifies with the enrolled public key.
static func sign(challenge: Data, keyHandle: Data) throws -> Data {
let context = LAContext()
context.localizedReason = "Confirm the transfer"
let key = try SecureEnclave.P256.Signing.PrivateKey(
dataRepresentation: keyHandle, authenticationContext: context)
return try key.signature(for: challenge).derRepresentation
}
}App Attest generates a Secure Enclave key for your app, and Apple certifies that it belongs to a genuine instance on genuine hardware. Make one key per user per install, attest it with the SHA-256 of a single-use server challenge of at least 16 bytes, then sign sensitive requests with generateAssertion. Retry serverUnavailable errors with the same key; discard the key after any other error. Keys survive updates but not reinstalls, migration or restore. isSupported is false in the Simulator, and most app extensions can't use the service.
The server does the trust work. For an attestation, check the certificate chain up to Apple's App Attest root, the nonce in the extension with OID 1.2.840.113635.100.8.2, the RP ID hash of your App ID, a zero counter, the development or production AAGUID, and the key ID. For each assertion, verify the signature with the stored key and require a higher counter. From iOS 27, authenticator data also reports the launch validation category, such as App Store or TestFlight, and the bundle version. A receipt-based fraud metric counts attested keys per device over 30 days. DeviceCheck's DCDevice adds two server-side bits per device.
import CryptoKit
import DeviceCheck
import Foundation
protocol AttestAPI {
func challenge() async throws -> Data // ≥16 random bytes, single use
func register(keyID: String, attestation: Data) async throws
}
// Run once per user per install, then keep keyID in the Keychain.
func enrollAppAttest(api: AttestAPI) async throws -> String? {
let service = DCAppAttestService.shared
guard service.isSupported else { return nil } // fall back to other risk signals
let keyID = try await service.generateKey()
let clientDataHash = Data(SHA256.hash(data: try await api.challenge()))
let attestation = try await service.attestKey(keyID, clientDataHash: clientDataHash)
try await api.register(keyID: keyID, attestation: attestation) // server verifies, then trusts keyID
return keyID
}
// Sign a sensitive request: the server checks the signature, counter and challenge.
func assertion(for requestBody: Data, keyID: String) async throws -> Data {
let clientDataHash = Data(SHA256.hash(data: requestBody)) // body embeds the server challenge
return try await DCAppAttestService.shared.generateAssertion(keyID, clientDataHash: clientDataHash)
}MASVS-PRIVACY asks apps to minimize data and say what they collect; Apple turns part of that into build requirements. PrivacyInfo.xcprivacy declares the data types the app collects, whether it tracks, its tracking domains, and its reasons for calling required-reason APIs such as UserDefaults, file timestamps, system boot time, disk space and active keyboards. Since 1 May 2024, App Store Connect rejects uploads that use those APIs without an approved reason. SDKs on Apple's list must ship their own manifest, signed when used as a binary. Requests to the declared tracking domains fail until the user allows tracking.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeEmailAddress</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<!-- clearKeychainAfterReinstall() reads and writes the app's own defaults -->
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>CVE-2021-30860 (FORCEDENTRY) was a zero-click iOS exploit used by Pegasus spyware: a crafted PDF triggered an integer overflow in CoreGraphics image parsing and ran code with no user interaction. It shows why keeping parsing libraries patched and minimizing attack surface matters even on a hardened platform.
Run these against the build you submit. The Simulator doesn't enforce Data Protection and can't run App Attest, so finish on a device. To run the Keychain, ATS, link and bridge checks as tests, the iOS project on /labs/, Keep an iOS app's storage, links and web bridge in bounds, puts a vulnerable and a fixed version of one SwiftUI app through an XCTest suite in the Simulator, mapped to MASTG-TEST-0300, MASTG-TEST-0322, MASTG-TEST-0395 and MASTG-TEST-0376.
APP=Bank.app; HOST=api.bank.example.com; LINKS=bank.example.com
# 1. ATS and app-bound domain settings that shipped in the bundle
plutil -p "$APP/Info.plist" | grep -A12 -E 'NSAppTransportSecurity|WKAppBoundDomains'
# 2. Entitlements: associated domains, keychain groups, App Attest environment, get-task-allow
codesign -d --entitlements - "$APP"
# 3. Does the server pass ATS with no exceptions? (macOS; expect "Result : PASS")
nscurl --ats-diagnostics "https://$HOST" | grep -A1 'ATS Default Connection'
# 4. SPKI pin of the issuing CA (second certificate in the chain)
openssl s_client -connect "$HOST:443" -servername "$HOST" -showcerts </dev/null 2>/dev/null \
| awk '/BEGIN CERT/{n++} n==2' | sed '/END CERT/q' \
| openssl x509 -pubkey -noout | openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary | openssl enc -base64
# 5. The association file must come back 200 over HTTPS with no redirect
curl -sS -o /dev/null -w '%{http_code} %{content_type} %{redirect_url}\n' \
"https://$LINKS/.well-known/apple-app-site-association"
# 6. Throw malformed links at a Simulator build; none may open an invoice screen
xcrun simctl openurl booted "https://$LINKS/invoices/INV0000123%0A"Guided practice
A banking app is in security review before its iOS 27 release. Decide what an attacker can do with each excerpt, then write the fix.
Supplied scenario material
Info.plist
NSAppTransportSecurity = { NSAllowsArbitraryLoads = true }
CFBundleURLTypes: scheme "bankapp" (used for payment links and the OAuth callback)
Keychain write
kSecClass = kSecClassGenericPassword, kSecAttrAccount = "refresh"
kSecAttrAccessible = kSecAttrAccessibleAlways, kSecAttrSynchronizable = true
Payment confirmation
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: "Pay") { ok, _ in
if ok { api.pay(invoice) }
}
Help screen (WKWebView loads https://help.bank.example.com, which embeds a third-party chat iframe)
userContentController.add(self, name: "native")
func userContentController(_ c: WKUserContentController, didReceive m: WKScriptMessage) {
if let s = m.body as? String, let url = URL(string: s) { UIApplication.shared.open(url) }
}
App Attest
if !DCAppAttestService.shared.isSupported { blockUser() }
The app checks its own attestation object and sends the server attested=true.1. Restore transport security
Explain what
NSAllowsArbitraryLoads = trueallows and write the replacement.Expected result and reasoning
It turns ATS off for every host, so plain HTTP and weak TLS are accepted and App Review asks for a justification. Remove the key, fix the servers, and add a per-domain exception only for a host that truly can't be fixed.
2. Fix the token storage
Describe where the refresh token can end up and how readable it is, then rewrite the attributes.
Expected result and reasoning
Always(deprecated since iOS 12) makes it readable while the phone is locked, andsynchronizablecopies it to iCloud Keychain and every signed-in device. UsekSecAttrAccessibleWhenUnlockedThisDeviceOnlywithoutkSecAttrSynchronizable, and clear the item on the first launch after an install.3. Make the payment check mean something
Explain how the biometric check can be bypassed and redesign it.
Expected result and reasoning
evaluatePolicyonly returns a Boolean; hooking the closure or callingapi.paydirectly skips it, and the server learns nothing. Sign a one-time server challenge with a Secure Enclave key created with [.privateKeyUsage,.biometryCurrentSet], and have the server verify the signature before it pays.4. Close the WebView bridge
Identify who can call the native handler and what they can make the app do.
Expected result and reasoning
Any frame, including the third-party chat iframe, can post a message, and the app opens any URL it sends: other apps' schemes, phishing pages,
tel:links. CheckisMainFrameand an exact https origin, accept a fixed set of action names, and keep navigation on your own host.5. Move attestation to the server
Assess the App Attest logic and the
bankappscheme used for the OAuth callback.Expected result and reasoning
A modified app can send
attested=true, and blocking every unsupported device locks out legitimate users, Simulator builds included. Send the attestation object to the server, verify it there, and treat an unexpected unsupported result as a risk signal. Any app can claimbankapp, so run sign-in throughASWebAuthenticationSessionwith PKCE, using an https callback on an associated domain from iOS 17.4.
Paper exercise: the excerpts are complete for the questions asked. App Attest needs a physical device, and Data Protection behavior can't be observed in the Simulator. The iOS project on /labs/ turns the transport, storage, link and WebView findings into failing XCTest cases you repair.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Run a local project
Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.
Keep an iOS app's storage, links and web bridge in bounds
Repair a SwiftUI banking app until its XCTest suite passes in the iOS Simulator: Keychain storage, App Transport Security in the built Info.plist, link handling and a WKWebView bridge.
Needs: A Mac with Xcode 16 or later and an iOS Simulator; no signing or developer account.
In the extracted folder, run:
sh run-tests.sh19 regression tests · The starter intentionally fails some cases.
No dev container: the iOS SDK runs only on macOS.
Real Keychain, ATS and WebKit behavior in the Simulator. Data Protection and universal-link association need a device or a signed build. Verified with Xcode 27.0 on iOS 27.0 and 26.5.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- OWASP MASVS 2.1.0 release and control list
- Apple Developer: Restricting keychain item accessibility
- Apple Platform Security: Data Protection classes
- Apple Developer: Preventing insecure network connections (App Transport Security)
- Apple Developer News: Identity pinning, how to configure server certificates for your app
- Apple Developer: Supporting associated domains
- WebKit blog: App-Bound Domains. 26 June 2020.
- Apple Developer: Protecting keys with the Secure Enclave
- Apple Developer: Validating apps that connect to your server (App Attest)
- Apple Developer: Describing use of required reason API
- NVD: CVE-2021-30860, iOS CoreGraphics zero-click (FORCEDENTRY)
- OWASP MASTG v2.0.0 release. v2.0.0.
- WebKit source: WebsiteDataStoreCocoa.mm, where WKAppBoundDomains entries become registrable domains (at most 10)
Reference information
- SDLC phase
- Development
- OWASP MASVS v2 controls
- MASVS-STORAGE-1 · MASVS-STORAGE-2 · MASVS-CRYPTO-1 · MASVS-CRYPTO-2 · MASVS-AUTH-2 · MASVS-AUTH-3 · MASVS-NETWORK-1 · MASVS-NETWORK-2 · MASVS-PLATFORM-1 · MASVS-PLATFORM-2 · MASVS-PLATFORM-3 · MASVS-CODE-4 · MASVS-RESILIENCE-1 · MASVS-RESILIENCE-2 · MASVS-PRIVACY-1 · MASVS-PRIVACY-3