---
key: "ios-secure-coding"
title: "Harden an iOS app's storage, network and entry points"
url: "https://softwaresecurity.com/domains/appsec/platform-security/ios-secure-coding/"
api: "https://softwaresecurity.com/api/v1/articles/ios-secure-coding.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-312","CWE-319","CWE-295","CWE-939","CWE-940","CWE-749","CWE-532","CWE-602","A01:2025","A02:2025","A04:2025","A06:2025","A07:2025"]
tags: ["Key management","Mobile apps"]
stages: ["build"]
roles: ["developer","appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Harden an iOS app's storage, network and entry points

Store secrets in the Keychain with the narrowest accessibility class, set file protection explicitly, keep App Transport Security on, prefer universal links, constrain WKWebView bridges, keep step-up keys in the Secure Enclave, verify App Attest on the server and ship an accurate privacy manifest, mapped to OWASP MASVS 2.1.

Canonical URL: https://softwaresecurity.com/domains/appsec/platform-security/ios-secure-coding/

## In short

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; `ThisDeviceOnly` classes 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 `WKAppBoundDomains` entries by registrable domain: listing `help.bank.example.com` also admits `example.com`.
- An `LAContext` Boolean 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 `isSupported` is false in the Simulator. Verify everything on the server.

## Recommended defaults

| 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 |

## Verify it

1. Inspect the bundle you submit: `plutil -p` on `Info.plist` for ATS and app-bound domain keys, and `codesign -d --entitlements -` for associated domains, keychain groups and the App Attest environment.
2. Run `nscurl --ats-diagnostics` against each API host and confirm the default ATS connection passes with no exceptions.
3. Fetch `/.well-known/apple-app-site-association` from every associated domain and confirm a 200 over HTTPS with no redirect.
4. Open malformed universal links and custom-scheme URLs on a device and confirm nothing happens beyond a rejected or read-only screen.
5. Delete and reinstall the app, then confirm it starts signed out with no tokens left in the Keychain.
6. 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.
7. Run the iOS project's XCTest suite in the Simulator: the token must read back from the Keychain as `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`, a plain `http://` request must fail with `NSURLErrorAppTransportSecurityRequiresSecureConnection`, and a message from an iframe or another origin must do nothing.

## 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 `WKWebView` bridges to untrusted callers.
- Use Secure Enclave keys, App Attest and the privacy manifest as verifiable controls, mapped to MASVS 2.1.

Audience: iOS developers and mobile AppSec reviewers who ship or assess Swift apps

Before you start:

- Read Swift and `Info.plist` entries.
- Know how the Keychain, entitlements and URL handling fit into an iOS app.
- Understand TLS certificate validation at a basic level.

Time: About 36 minutes: 10 to read and 26 for the exercise

## Map each MASVS 2.1 control to an iOS mechanism

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 |

## Store secrets in the Keychain with the narrowest class

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.

```swift
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")
}
```

## Set file protection explicitly

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.

```swift
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
}
```

## Keep secrets out of logs, snapshots, the pasteboard and caches

`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 `sceneDidEnterBackground` and 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.

```swift
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)
```

## Keep App Transport Security on

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
<?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>
```

## Pin with NSPinnedDomains only when you can rotate

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
<?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 -->
```

## Prefer universal links and validate every URL

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.

```swift
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]))
}
```

## Constrain WKWebView and its message handlers

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`.

```swift
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
}
```

## Bind step-up to a Secure Enclave key

`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.

```swift
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
    }
}
```

## Prove app integrity with App Attest and check it on the server

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.

```swift
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)
}
```

## Ship an accurate privacy manifest

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
<?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>
```

## A real-world failure

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.

## Check the shipped bundle and a real device

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.

```bash
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 material

```text
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 = true` allows and write the replacement.

Worked answer: 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.

Worked answer: `Always` (deprecated since iOS 12) makes it readable while the phone is locked, and `synchronizable` copies it to iCloud Keychain and every signed-in device. Use `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` without `kSecAttrSynchronizable`, 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.

Worked answer: `evaluatePolicy` only returns a Boolean; hooking the closure or calling `api.pay` directly 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.

Worked answer: 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. Check `isMainFrame` and 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 `bankapp` scheme used for the OAuth callback.

Worked answer: 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 claim `bankapp`, so run sign-in through `ASWebAuthenticationSession` with PKCE, using an https callback on an associated domain from iOS 17.4.

Deliverable: Five findings, each with its MASVS control, the attack, and the corrected code or configuration.

Scope: 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.

## Executable practice

- [Keep an iOS app's storage, links and web bridge in bounds: project ZIP](https://softwaresecurity.com/downloads/projects/ios-platform-boundaries-5d4c4cd9417f9c64.zip) — Swift 6 · Xcode 16+ · iOS Simulator

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [OWASP MASVS 2.1.0 release and control list](https://github.com/OWASP/masvs/releases/tag/v2.1.0)
- [Apple Developer: Restricting keychain item accessibility](https://developer.apple.com/documentation/security/restricting-keychain-item-accessibility)
- [Apple Platform Security: Data Protection classes](https://support.apple.com/guide/security/data-protection-classes-secb010e978a/web)
- [Apple Developer: Preventing insecure network connections (App Transport Security)](https://developer.apple.com/documentation/security/preventing-insecure-network-connections)
- [Apple Developer News: Identity pinning, how to configure server certificates for your app](https://developer.apple.com/news/?id=g9ejcf8y)
- [Apple Developer: Supporting associated domains](https://developer.apple.com/documentation/xcode/supporting-associated-domains)
- [WebKit blog: App-Bound Domains](https://webkit.org/blog/10882/app-bound-domains/). 26 June 2020.
- [Apple Developer: Protecting keys with the Secure Enclave](https://developer.apple.com/documentation/security/protecting-keys-with-the-secure-enclave)
- [Apple Developer: Validating apps that connect to your server (App Attest)](https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server)
- [Apple Developer: Describing use of required reason API](https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api)
- [NVD: CVE-2021-30860, iOS CoreGraphics zero-click (FORCEDENTRY)](https://nvd.nist.gov/vuln/detail/cve-2021-30860)
- [OWASP MASTG v2.0.0 release](https://github.com/OWASP/mastg/releases/tag/v2.0.0). v2.0.0.
- [WebKit source: WebsiteDataStoreCocoa.mm, where WKAppBoundDomains entries become registrable domains (at most 10)](https://github.com/WebKit/WebKit/blob/main/Source/WebKit/UIProcess/WebsiteData/Cocoa/WebsiteDataStoreCocoa.mm)