Security and Privacy

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Every FieldNotes feature added so far stores something worth protecting: the notes themselves, the session token the app uses to sync them, and the location and photos attached to them. This page shows how the app protects those things and how it tells people what it does with their data. It uses the APIs of the 27 releases (iOS, iPadOS, macOS, watchOS and visionOS 27), built with Xcode 27 and Swift 6.4, and keeps the app’s deployment minimum of 26. Most of the APIs here are older than 26. Trust Insights is the one framework that is new in 27, and every use of it below is guarded with if #available.

The page adds three types to the FieldNotesServices module of FieldNotesKit. KeychainStore wraps Keychain Services for the service com.example.fieldnotes.auth. BiometricLock hides notes behind Face ID, Touch ID or Optic ID. AccountManager signs people in with Sign in with Apple or a passkey for the relying party fieldnotes.example.com. The protocol side of signing in (OAuth 2.0, OpenID Connect, PKCE, ASWebAuthenticationSession, universal links) is already covered on Native and Mobile Apps and is not repeated here. The same problems solved in React Native are on React Native Security.

The Security Toolbox at a Glance

Apple’s security APIs split into four jobs. The table maps each job to the framework FieldNotes uses and the section below that covers it.

Job Framework or file FieldNotes type or artifact

Keep secrets at rest

Security (Keychain Services), CryptoKit

KeychainStore, Secure Enclave signing key

Prove the person is present

LocalAuthentication

BiometricLock

Prove who the person is

AuthenticationServices (Sign in with Apple, passkeys)

AccountManager

Declare and limit what the app can do

Entitlements, App Sandbox, PrivacyInfo.xcprivacy, App Tracking Transparency, App Attest, Trust Insights

FieldNotes.entitlements, FieldNotes-macOS.entitlements, PrivacyInfo.xcprivacy

Platform availability

API or feature iOS iPadOS macOS watchOS visionOS

Keychain Services (SecItem*)

Yes

Yes

Yes (data protection keychain)

Yes

Yes

LocalAuthentication biometrics

Face ID, Touch ID

Face ID, Touch ID

Touch ID

Not available (passcode and wrist detection only)

Optic ID

CryptoKit (hashes, HPKE, Secure Enclave)

Yes

Yes

Yes

Yes

Yes

CryptoKit ML-KEM, ML-DSA, X-Wing

New in 26

New in 26

New in 26

New in 26

New in 26

SignInWithAppleButton

Yes

Yes

Yes

Yes

Yes

Passkeys (ASAuthorizationPlatformPublicKeyCredentialProvider)

Yes

Yes

Yes

Not available

Yes

Passkey account creation, ASCredentialUpdater

New in 26

New in 26

New in 26

Not available

New in 26

App Sandbox

Always on (not configurable)

Yes (always on)

Opt-in entitlement, required for the Mac App Store

Yes (always on)

Yes (always on)

Privacy manifest required-reason entries

Yes

Yes

Yes (data types only)

Yes

Yes

App Tracking Transparency

Yes

Yes

Yes

Not available

Yes

App Attest (DCAppAttestService)

Yes

Yes

New in 27 (Mac apps, see App Attest and DeviceCheck)

Yes

Yes

Trust Insights

New in 27

New in 27

New in 27 (Mac Catalyst only)

Not available

Not available

Keychain Services and KeychainStore

The Keychain is an encrypted database managed by the system, not by the app. Items survive app deletion on some platforms, are encrypted with keys derived from the device passcode and hardware, and are never included in the app’s sandbox container. That makes it the only right place for FieldNotes’s session token and refresh token; UserDefaults, @AppStorage and files are not (see State and Data Flow in SwiftUI and Preferences, Files and Documents).

The C API has four functions, each taking a dictionary of kSec…​ keys:

  • SecItemAdd(_:_:) stores a new item;

  • SecItemCopyMatching(_:_:) finds items and returns their data or attributes;

  • SecItemUpdate(_:_:) changes the data or attributes of matching items;

  • SecItemDelete(_:) removes matching items.

Each returns an OSStatus. errSecSuccess, errSecItemNotFound and errSecDuplicateItem are the three a wrapper must handle. KeychainStore hides the dictionaries behind a small Swift API:

// Packages/FieldNotesKit/Sources/FieldNotesServices/Security/KeychainStore.swift
import Foundation
import Security

public struct KeychainStore: Sendable {
    public enum KeychainError: Error, Equatable {
        case unexpectedStatus(OSStatus)
        case unexpectedData
    }

    public let service: String
    public let accessGroup: String?

    public init(service: String = "com.example.fieldnotes.auth",
                accessGroup: String? = "group.com.example.fieldnotes") {
        self.service = service
        self.accessGroup = accessGroup
    }

    /// The attributes that identify one item: class, service, account and group.
    private func baseQuery(account: String) -> [String: Any] {
        var query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecUseDataProtectionKeychain as String: true,   // iOS-style keychain on macOS too
        ]
        if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup }
        return query
    }

    /// Adds the item, or updates it if it already exists.
    public func set(_ data: Data, for account: String,
                    accessible: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) throws {
        var attributes = baseQuery(account: account)
        attributes[kSecValueData as String] = data
        attributes[kSecAttrAccessible as String] = accessible

        let status = SecItemAdd(attributes as CFDictionary, nil)
        switch status {
        case errSecSuccess:
            return
        case errSecDuplicateItem:
            try update(data, for: account)
        default:
            throw KeychainError.unexpectedStatus(status)
        }
    }

    public func data(for account: String) throws -> Data? {
        var query = baseQuery(account: account)
        query[kSecReturnData as String] = true
        query[kSecMatchLimit as String] = kSecMatchLimitOne

        var result: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        switch status {
        case errSecSuccess:
            guard let data = result as? Data else { throw KeychainError.unexpectedData }
            return data
        case errSecItemNotFound:
            return nil
        default:
            throw KeychainError.unexpectedStatus(status)
        }
    }

    public func update(_ data: Data, for account: String) throws {
        let changes: [String: Any] = [kSecValueData as String: data]
        let status = SecItemUpdate(baseQuery(account: account) as CFDictionary, changes as CFDictionary)
        guard status == errSecSuccess else { throw KeychainError.unexpectedStatus(status) }
    }

    public func delete(account: String) throws {
        let status = SecItemDelete(baseQuery(account: account) as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.unexpectedStatus(status)
        }
    }
}

KeychainStore is a Sendable value type with only let properties, so any actor can hold one; the Keychain itself is thread-safe. The [String: Any] dictionaries never leave the function that builds them, which keeps them out of the Sendable checks described in Actors, Isolation and Sendable. Error handling follows the typed-error pattern from Error Handling.

kSecUseDataProtectionKeychain matters only on the Mac. Without it, a macOS app writes to the legacy file-based login keychain, which ignores accessibility classes and access groups. With it, the Mac build behaves exactly like the iOS one.

Accessibility classes

The kSecAttrAccessible attribute decides when the system can decrypt an item. The item is encrypted with a class key, and the system discards some class keys when the device locks. Pick the most restrictive class the feature allows:

Class Readable when FieldNotes use

kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly

Device unlocked and a passcode is set; the item is deleted if the passcode is removed

The key that BiometricLock protects

kSecAttrAccessibleWhenUnlocked / …​ThisDeviceOnly

Device unlocked

Nothing yet: foreground-only secrets

kSecAttrAccessibleAfterFirstUnlock / …​ThisDeviceOnly

Any time after the first unlock since boot

The session token, which background refresh and the widget need while the screen is locked

The ThisDeviceOnly variants never leave the device: they are excluded from encrypted backups restored to another device and from iCloud Keychain. A session token is bound to one device’s login, so FieldNotes uses the ThisDeviceOnly form. kSecAttrAccessibleAlways and its ThisDeviceOnly variant are deprecated; don’t use them.

// FieldNotesServices: storing the token so background tasks can read it after first unlock.
let keychain = KeychainStore()
try keychain.set(Data(sessionToken.utf8), for: "session",
                 accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)

The background refresh task (com.example.fieldnotes.refresh, on Background Execution) is the reason the token cannot use a WhenUnlocked class: it runs while the phone is in a pocket. The flowchart shows which items the system can decrypt in each lock state.

Figure: Keychain protection classes vs device lock state

flowchart TD Start["App calls SecItemCopyMatching"] --> Boot{"Unlocked at least once
since boot?"} Boot -- No --> None["No data protection item is readable
errSecInteractionNotAllowed"] Boot -- Yes --> Now{"Device unlocked
right now?"} Now -- No --> AFU["Readable: AfterFirstUnlock
and AfterFirstUnlockThisDeviceOnly
(session token, widget, background refresh)"] Now -- Yes --> Pass{"Passcode set?"} Pass -- Yes --> All["Readable: every class, including
WhenPasscodeSetThisDeviceOnly
(BiometricLock key, after Face ID)"] Pass -- No --> NoPass["Readable: WhenUnlocked and AfterFirstUnlock
WhenPasscodeSet items were deleted
when the passcode was removed"] AFU -.-> Lock["Screen locks: WhenUnlocked class keys
are discarded shortly after"]

Access groups: sharing with the widget and extensions

An item belongs to exactly one access group. By default that group is the app’s own application identifier (team ID plus bundle ID), so only the app can read it. The FieldNotes widget (Widgets, Complications and Controls), the share extension and the notification service extension are separate processes with their own bundle IDs, and they need the session token too. Every target already belongs to the App Group group.com.example.fieldnotes, and an App Group identifier is also a valid keychain access group. That is why KeychainStore defaults accessGroup to it.

<!-- FieldNotes/FieldNotes.entitlements (excerpt): the same group in every target that shares the token -->
<key>com.apple.security.application-groups</key>
<array>
    <string>group.com.example.fieldnotes</string>
</array>

Needs the App Groups capability on every target that shares items, all signed by the same team. The alternative is the Keychain Sharing capability, which writes a keychain-access-groups entitlement with a dedicated group name such as $(AppIdentifierPrefix)com.example.fieldnotes.shared; use it when targets should share keychain items without also sharing a container.

LocalAuthentication and BiometricLock

Some people keep private field notes and want them hidden when someone else picks up the device. BiometricLock hides the note list until the owner authenticates with Face ID (iPhone, iPad), Touch ID (iPhone SE, iPad, Mac), Optic ID (Apple Vision Pro) or the device passcode. It uses LAContext, the entry point to LocalAuthentication: canEvaluatePolicy(_:error:) checks what is possible, biometryType says which sensor is present, and evaluatePolicy(_:localizedReason:) shows the system prompt.

// Packages/FieldNotesKit/Sources/FieldNotesServices/Security/BiometricLock.swift
import LocalAuthentication
import Observation

@Observable @MainActor
public final class BiometricLock {
    public private(set) var isLocked = true
    public private(set) var sensorName = "Passcode"

    public init() {
        let context = LAContext()
        var error: NSError?
        #if !os(watchOS)
        if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
            switch context.biometryType {
            case .faceID:  sensorName = "Face ID"
            case .touchID: sensorName = "Touch ID"
            case .opticID: sensorName = "Optic ID"
            default:       break
            }
        }
        #endif
    }

    /// Asks for biometrics, falling back to the device passcode.
    public func unlock() async {
        let context = LAContext()
        context.localizedCancelTitle = "Keep Locked"
        do {
            isLocked = !(try await context.evaluatePolicy(
                .deviceOwnerAuthentication,
                localizedReason: "Unlock your private notes"))
        } catch {
            isLocked = true   // LAError.userCancel, .biometryLockout, ...
        }
    }

    public func lock() { isLocked = true }
}

Needs the NSFaceIDUsageDescription key in Info.plist before Face ID can be used, and a physical device with enrolled biometrics to test the real sensor (the simulator offers a Features > Face ID menu to simulate matches and failures).

.deviceOwnerAuthentication accepts biometrics or the passcode, which is what a lock screen for notes wants: someone whose Face ID stops working is not locked out of their own notes. .deviceOwnerAuthenticationWithBiometrics refuses the passcode and suits a "confirm with Face ID" step inside an already-unlocked session. On watchOS the biometric policy does not exist, so the #if !os(watchOS) guard keeps the watch app compiling; there .deviceOwnerAuthentication uses the watch passcode and wrist detection.

The app locks again whenever it leaves the foreground:

// FieldNotes/Views/ContentView.swift (excerpt)
@Environment(BiometricLock.self) private var biometricLock
@Environment(\.scenePhase) private var scenePhase

var body: some View {
    NavigationSplitView { /* sidebar, NoteList(notebookID:selection:), NoteDetail(note:) */ }
        .overlay {
            if biometricLock.isLocked {
                ContentUnavailableView {
                    Label("Notes Locked", systemImage: "lock.fill")
                } actions: {
                    Button("Unlock with \(biometricLock.sensorName)") {
                        Task { await biometricLock.unlock() }
                    }
                }
                .background(.background)
            }
        }
        .onChange(of: scenePhase) { _, phase in
            if phase == .background { biometricLock.lock() }
        }
}

FieldNotesApp owns the lock with @State private var biometricLock = BiometricLock() and injects it with .environment(biometricLock) next to the existing .environment(store), the same pattern as NoteStore on App Architecture.

Keychain items protected by biometrics

An overlay is only UI; the notes are still in memory. For a secret that must be cryptographically bound to the owner’s face or finger, store it in the Keychain with an access control object. .biometryCurrentSet means the item can be read only after a biometric match with the currently enrolled faces or fingers; if someone adds a new fingerprint, the item becomes unreadable. The item’s class then must be kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly.

// FieldNotesServices/Security/KeychainStore+Biometry.swift
import LocalAuthentication
import Security

extension KeychainStore {
    /// Stores data that can only be read after Face ID, Touch ID or Optic ID.
    public func setBiometryProtected(_ data: Data, for account: String) throws {
        guard let access = SecAccessControlCreateWithFlags(
            nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .biometryCurrentSet, nil)
        else { throw KeychainError.unexpectedStatus(errSecParam) }

        var attributes: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecUseDataProtectionKeychain as String: true,
            kSecAttrAccessControl as String: access,      // replaces kSecAttrAccessible
            kSecValueData as String: data,
        ]
        if let accessGroup { attributes[kSecAttrAccessGroup as String] = accessGroup }
        let status = SecItemAdd(attributes as CFDictionary, nil)
        guard status == errSecSuccess else { throw KeychainError.unexpectedStatus(status) }
    }

    /// Reads it, using an LAContext so the system prompt shows FieldNotes's reason.
    public func biometryProtectedData(for account: String, context: LAContext) throws -> Data? {
        context.localizedReason = "Unlock your private notes"
        var query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecUseDataProtectionKeychain as String: true,
            kSecReturnData as String: true,
            kSecUseAuthenticationContext as String: context,
        ]
        if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup }
        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
    }
}

SecItemCopyMatching blocks while the prompt is on screen, so call biometryProtectedData(for:context:) from a background task or an actor, never from a SwiftUI body. Passing an LAContext that BiometricLock has already evaluated lets the Keychain reuse that match instead of prompting twice. Setting the context’s interactionNotAllowed to true turns a would-be prompt into an error, which is how a background task checks for an item without surprising the user.

CryptoKit

CryptoKit is the Swift-native cryptography framework. Its types are strongly typed (a SHA256.Digest cannot be passed where a SymmetricKey is expected) and it uses the Secure Enclave where the hardware has one. FieldNotes uses it for three things.

Hashing

A notebook export (see Preferences, Files and Documents) carries a SHA-256 checksum so the importer can detect a truncated file. The same hash function prepares the nonce for Sign in with Apple, below.

import CryptoKit

let digest = SHA256.hash(data: exportedData)
let checksum = digest.map { String(format: "%02x", $0) }.joined()

Insecure.MD5 and Insecure.SHA1 exist only for interoperating with old formats; the Insecure namespace is a warning in the type name.

HPKE and quantum-secure encryption

FieldNotes lets a person send one note to a colleague so that only the colleague’s device can read it, even if the FieldNotes server stores it. That is the job of hybrid public key encryption (HPKE, RFC 9180): the sender encrypts to the recipient’s public key, and the recipient opens the message with the private key.

Since the 26 releases, CryptoKit includes the post-quantum ML-KEM and ML-DSA algorithms and the X-Wing hybrid KEM, which combines ML-KEM-768 with X25519. An attacker who records the ciphertext today and gets a quantum computer later still cannot read it, and the classical half keeps it safe if a flaw is found in ML-KEM. Because FieldNotes’s deployment minimum is 26, it can use the X-Wing cipher suite with no availability check:

// FieldNotesServices/Security/NoteSharingCrypto.swift
import CryptoKit
import FieldNotesModel
import Foundation

enum NoteSharingCrypto {
    static let suite = HPKE.Ciphersuite.XWingMLKEM768X25519_SHA256_AES_GCM_256
    static let info = Data("com.example.fieldnotes.share.v1".utf8)

    /// Sender: encrypts a note for a colleague's public key.
    static func seal(_ note: Note, for recipient: XWingMLKEM768X25519.PublicKey)
        throws -> (encapsulatedKey: Data, ciphertext: Data) {
        var sender = try HPKE.Sender(recipientKey: recipient, ciphersuite: suite, info: info)
        let plaintext = Data("\(note.title)\n\n\(note.body)".utf8)
        let ciphertext = try sender.seal(plaintext, authenticating: Data(note.id.uuidString.utf8))
        return (sender.encapsulatedKey, ciphertext)
    }

    /// Recipient: opens it with the private key kept in the Keychain.
    static func open(_ ciphertext: Data, encapsulatedKey: Data, noteID: UUID,
                     with privateKey: XWingMLKEM768X25519.PrivateKey) throws -> Data {
        var recipient = try HPKE.Recipient(privateKey: privateKey, ciphersuite: suite,
                                           info: info, encapsulatedKey: encapsulatedKey)
        return try recipient.open(ciphertext, authenticating: Data(noteID.uuidString.utf8))
    }
}

The note’s ID is passed as authenticated data: it is not encrypted, but any change to it makes open throw, so a server cannot swap one note’s ciphertext for another’s. NoteSharingCrypto is an internal helper, not a public API of FieldNotesServices.

Secure Enclave keys

The Secure Enclave is a separate processor that generates and uses private keys that never leave it. The app gets only an opaque, device-bound blob (dataRepresentation) that it can store in the Keychain and later hand back to the Secure Enclave. FieldNotes creates one P-256 signing key per device and uses it to sign sync requests, so the server can tell a stolen token used from another device from a genuine request.

// FieldNotesServices/Security/DeviceSigningKey.swift
import CryptoKit
import Foundation
import LocalAuthentication
import Security

enum DeviceSigningKey {
    static let account = "device-signing-key"

    static func loadOrCreate(in keychain: KeychainStore) throws -> SecureEnclave.P256.Signing.PrivateKey? {
        guard SecureEnclave.isAvailable else { return nil }          // older Macs, the simulator
        if let blob = try keychain.data(for: account) {
            return try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: blob)
        }
        guard let access = SecAccessControlCreateWithFlags(
            nil, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, .privateKeyUsage, nil)
        else { return nil }
        let key = try SecureEnclave.P256.Signing.PrivateKey(accessControl: access)
        try keychain.set(key.dataRepresentation, for: account)
        return key
    }
}

// Signing a request body:
// let signature = try key.signature(for: body)          // P256.Signing.ECDSASignature
// request.setValue(signature.derRepresentation.base64EncodedString(), forHTTPHeaderField: "FN-Signature")

Needs a physical device with a Secure Enclave; SecureEnclave.isAvailable is false in the simulator.

The same pattern works for a quantum-secure signature: SecureEnclave.MLDSA65.PrivateKey(accessControl:) (new in 26) creates an ML-DSA-65 key in the Secure Enclave, and signature(for:) signs with it. A server that wants to survive both classical and quantum attacks can require both signatures, concatenated, as the quantum-secure workflows article shows.

let pqAccess = SecAccessControlCreateWithFlags(
    nil, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, .privateKeyUsage, nil)!
let pqKey = try SecureEnclave.MLDSA65.PrivateKey(accessControl: pqAccess)
let pqSignature = try pqKey.signature(for: body)

Signing In: AccountManager

FieldNotes offers two ways to create an account, and never a password:

  • Sign in with Apple, which returns a stable, app-scoped user ID and a signed identity token;

  • passkeys, WebAuthn credentials synced by iCloud Keychain and bound to the domain fieldnotes.example.com.

Sign in with Apple was, for a time, the default modern sign-in mechanism to reach for; Apple now recommends passkey-based authentication alongside it, or instead of it, for many flows, since a passkey needs no third-party identity provider at all. Both flows go through AuthenticationServices. AccountManager wraps them and stores the resulting session in KeychainStore. The server side is abstracted by a small page-local protocol, AccountServerAPI; in FieldNotes, NotesAPIClient (see Networking) provides the conformance against https://api.fieldnotes.example.com.

// Packages/FieldNotesKit/Sources/FieldNotesServices/Security/AccountManager.swift
import AuthenticationServices
import Foundation
import Observation

/// What AccountManager needs from the FieldNotes server. All values are opaque to the app.
public protocol AccountServerAPI: Sendable {
    func signInWithApple(identityToken: Data, rawNonce: String) async throws -> String       // session token
    func passkeyRegistrationChallenge(userName: String) async throws -> (challenge: Data, userID: Data)
    func finishPasskeyRegistration(credentialID: Data, attestationObject: Data?,
                                   clientDataJSON: Data) async throws -> String
    func passkeyAssertionChallenge() async throws -> Data
    func finishPasskeyAssertion(credentialID: Data, authenticatorData: Data, signature: Data,
                                userID: Data, clientDataJSON: Data) async throws -> String
}

@Observable @MainActor
public final class AccountManager {
    public enum State: Equatable { case signedOut, signedIn }

    public static let relyingParty = "fieldnotes.example.com"
    public private(set) var state: State = .signedOut

    // internal, not private: the Sign in with Apple and passkey extensions live in other files
    @ObservationIgnored let keychain: KeychainStore
    @ObservationIgnored let server: any AccountServerAPI
    @ObservationIgnored var pendingNonce: String?

    public init(server: any AccountServerAPI, keychain: KeychainStore = KeychainStore()) {
        self.server = server
        self.keychain = keychain
        if (try? keychain.data(for: "session")) != nil { state = .signedIn }
    }

    func storeSession(_ token: String) throws {
        try keychain.set(Data(token.utf8), for: "session")
        state = .signedIn
    }

    public func signOut() throws {
        try keychain.delete(account: "session")
        state = .signedOut
    }
}

AccountManager is main-actor isolated because it drives UI and because SwiftUI’s AuthorizationController is too. FieldNotesApp creates it once, as @State private var account = AccountManager(server: apiClient), and injects it with .environment(account).

Sign in with Apple

SignInWithAppleButton renders the system button with the approved look. Its onRequest closure configures the request: the scopes to ask for (name and email are shared only on the first sign-in) and a nonce. Its onCompletion closure receives the credential. The app sends the SHA-256 of a random nonce with the request and the raw nonce to its server, which checks that the identity token’s nonce claim matches, so a stolen token cannot be replayed.

// FieldNotesServices/Security/AccountManager+SignInWithApple.swift
import AuthenticationServices
import CryptoKit
import Foundation

extension AccountManager {
    public func prepare(_ request: ASAuthorizationAppleIDRequest) {
        let nonce = UUID().uuidString + UUID().uuidString
        pendingNonce = nonce
        request.requestedScopes = [.fullName, .email]
        request.nonce = SHA256.hash(data: Data(nonce.utf8))
            .map { String(format: "%02x", $0) }.joined()
    }

    public func complete(_ result: Result<ASAuthorization, any Error>) async throws {
        guard case .success(let authorization) = result,
              let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
              let token = credential.identityToken,
              let nonce = pendingNonce else { return }
        let session = try await server.signInWithApple(identityToken: token, rawNonce: nonce)
        try storeSession(session)
    }
}

// FieldNotes/Views/SignInView.swift
import AuthenticationServices
import SwiftUI

struct SignInView: View {
    @Environment(AccountManager.self) private var account
    @State private var signInError: String?

    var body: some View {
        SignInWithAppleButton(.signIn) { request in
            account.prepare(request)
        } onCompletion: { result in
            Task {
                do {
                    try await account.complete(result)
                } catch ASAuthorizationError.canceled {
                    // The person closed the sheet: not an error worth showing.
                } catch {
                    signInError = "Signing in didn't work. Please try again."
                }
            }
        }
        .signInWithAppleButtonStyle(.whiteOutline)
        .frame(height: 44)
        .alert(signInError ?? "", isPresented: .constant(signInError != nil)) {
            Button("OK") { signInError = nil }
        }
    }
}

Needs the Sign in with Apple capability (the com.apple.developer.applesignin entitlement), which requires a paid developer account.

A person can revoke FieldNotes’s access in Settings > Apple Account > Sign in with Apple. The app checks the credential state at launch and listens for revocation:

// FieldNotesApp.swift (excerpt): runs from .task on the main window
func verifyAppleIDCredential(userID: String) async {
    let state = try? await ASAuthorizationAppleIDProvider().credentialState(forUserID: userID)
    if state == .revoked || state == .notFound { try? account.signOut() }
}

// and: NotificationCenter.default.notifications(named:
//          ASAuthorizationAppleIDProvider.credentialRevokedNotification)

Sign in with Apple is itself an OpenID Connect provider. If FieldNotes’s backend federates several identity providers, the web-based flow with PKCE described on Native and Mobile Apps and Authorization Code and PKCE applies instead.

Passkeys

A passkey is a WebAuthn key pair. The private key stays in iCloud Keychain (or on a security key); the server stores only the public key. Nothing reusable crosses the network, so passkeys cannot be phished or leaked from a server breach. The protocol itself, and how passkeys compare with other passwordless methods, is on Authentication Methods: Passwordless and 2FA; this section covers only the Apple API.

ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier:) creates two kinds of request:

  • createCredentialRegistrationRequest(challenge:name:userID:) makes a new passkey (registration);

  • createCredentialAssertionRequest(challenge:) signs in with an existing one (assertion).

In SwiftUI, the authorizationController environment value performs the request with async/await, with no delegate:

// FieldNotesServices/Security/AccountManager+Passkeys.swift
#if !os(watchOS)
import AuthenticationServices
import SwiftUI   // AuthorizationController lives in the AuthenticationServices/SwiftUI cross-import overlay

extension AccountManager {
    private var passkeyProvider: ASAuthorizationPlatformPublicKeyCredentialProvider {
        ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: Self.relyingParty)
    }

    public func registerPasskey(userName: String, using controller: AuthorizationController) async throws {
        let (challenge, userID) = try await server.passkeyRegistrationChallenge(userName: userName)
        let request = passkeyProvider.createCredentialRegistrationRequest(
            challenge: challenge, name: userName, userID: userID)

        guard case .passkeyRegistration(let registration) =
                try await controller.performRequest(request) else { return }
        let session = try await server.finishPasskeyRegistration(
            credentialID: registration.credentialID,
            attestationObject: registration.rawAttestationObject,
            clientDataJSON: registration.rawClientDataJSON)
        try storeSession(session)
    }

    public func signInWithPasskey(using controller: AuthorizationController) async throws {
        let challenge = try await server.passkeyAssertionChallenge()
        let request = passkeyProvider.createCredentialAssertionRequest(challenge: challenge)

        guard case .passkeyAssertion(let assertion) =
                try await controller.performRequests([request],
                                                     options: .preferImmediatelyAvailableCredentials)
        else { return }
        let session = try await server.finishPasskeyAssertion(
            credentialID: assertion.credentialID,
            authenticatorData: assertion.rawAuthenticatorData,
            signature: assertion.signature,
            userID: assertion.userID,
            clientDataJSON: assertion.rawClientDataJSON)
        try storeSession(session)
    }
}
#endif

// FieldNotes/Views/SignInView.swift (passkey button)
#if !os(watchOS)
import SwiftUI

struct PasskeySignInButton: View {
    @Environment(AccountManager.self) private var account
    @Environment(\.authorizationController) private var authorizationController
    @State private var signInError: String?

    var body: some View {
        Button("Sign In with a Passkey", systemImage: "person.badge.key") {
            Task {
                do {
                    try await account.signInWithPasskey(using: authorizationController)
                } catch ASAuthorizationError.canceled {
                    // No passkey chosen: stay on the sign-in screen silently.
                } catch {
                    signInError = "No passkey could be used. Try Sign in with Apple instead."
                }
            }
        }
        .alert(signInError ?? "", isPresented: .constant(signInError != nil)) {
            Button("OK") { signInError = nil }
        }
    }
}
#endif

Needs the Associated Domains capability with the entry webcredentials:fieldnotes.example.com, and the server must publish an apple-app-site-association file listing the app’s ID under webcredentials; without it every request fails. Associated domains need a paid developer account. The AASA file format and universal links are covered on App Intents, Siri, Shortcuts, Spotlight, Deep Links and Extensions.

.preferImmediatelyAvailableCredentials makes the assertion fail quietly when the device has no passkey for fieldnotes.example.com, instead of offering to scan a QR code from another device. FieldNotes uses it on launch to sign in silently, then shows the full sign-in screen only if it fails.

Figure: passkey registration and assertion

sequenceDiagram autonumber actor P as Person participant App as FieldNotes (AccountManager) participant OS as AuthenticationServices and iCloud Keychain participant RP as fieldnotes.example.com Note over App,RP: Registration App->>RP: request registration challenge (user name) RP-->>App: challenge, userID App->>OS: createCredentialRegistrationRequest, performRequest OS->>OS: check webcredentials entry in apple-app-site-association OS->>P: Save a passkey for fieldnotes.example.com? P->>OS: Face ID, Touch ID or Optic ID OS->>OS: generate key pair, keep private key in iCloud Keychain OS-->>App: credentialID, attestationObject, clientDataJSON App->>RP: finish registration RP->>RP: verify challenge and origin, store public key RP-->>App: session token, saved by KeychainStore Note over App,RP: Assertion (later sign-in, any of the person's devices) App->>RP: request assertion challenge RP-->>App: challenge App->>OS: createCredentialAssertionRequest, performRequests OS->>P: Sign in to fieldnotes.example.com? P->>OS: biometric match OS->>OS: sign challenge with the private key OS-->>App: credentialID, authenticatorData, signature, userID, clientDataJSON App->>RP: finish assertion RP->>RP: verify signature with the stored public key RP-->>App: session token

Account creation and credential updates (26)

WWDC25 added two passkey APIs that FieldNotes adopts, both new in 26 and therefore usable without an availability check. ASAuthorizationAccountCreationProvider creates the account and its passkey in one system sheet, pre-filled with the person’s name and email or phone number, so there is no sign-up form at all. ASCredentialUpdater tells the system’s credential manager when something changed on the server: for example, that a passkey was deleted, so iCloud Keychain stops offering it.

#if !os(watchOS)
import AuthenticationServices
import SwiftUI   // AuthorizationController lives in the AuthenticationServices/SwiftUI cross-import overlay

extension AccountManager {
    public func createAccount(using controller: AuthorizationController) async throws {
        let (challenge, userID) = try await server.passkeyRegistrationChallenge(userName: "")
        let request = ASAuthorizationAccountCreationProvider()
            .createPlatformPublicKeyCredentialRegistrationRequest(
                acceptedContactIdentifiers: [.email],
                shouldRequestName: true,
                relyingPartyIdentifier: Self.relyingParty,
                challenge: challenge,
                userID: userID)
        guard case .passkeyAccountCreation(let created) =
                try await controller.performRequest(request) else { return }
        let registration = created.credentialRegistration
        let session = try await server.finishPasskeyRegistration(
            credentialID: registration.credentialID,
            attestationObject: registration.rawAttestationObject,
            clientDataJSON: registration.rawClientDataJSON)
        try storeSession(session)
    }

    /// After the server revoked a passkey, e.g. from the account page on the web.
    public func reportRemovedPasskey(credentialID: Data) async {
        try? await ASCredentialUpdater().reportUnknownPublicKeyCredential(
            relyingPartyIdentifier: Self.relyingParty, credentialID: credentialID)
    }
}
#endif

Entitlements and the App Sandbox

An entitlement is a signed key-value pair that grants the app a capability: iCloud, push, App Groups, Associated Domains, Sign in with Apple. Entitlements live in the target’s .entitlements file, are embedded in the code signature, and must match the provisioning profile; adding one through Signing & Capabilities updates both. How signing, profiles and capabilities fit together is the subject of Membership, Signing, Capabilities and Info.plist; this section lists only the security-relevant keys FieldNotes uses.

On iOS, iPadOS, watchOS and visionOS every app is sandboxed and there is nothing to configure. On macOS the App Sandbox is an entitlement. It is required for the Mac App Store and recommended everywhere: a sandboxed app gets its own container, cannot read other apps' files, and needs an explicit entitlement for each resource (network, camera, user-selected files, location). FieldNotes’s Mac build has its own entitlements file, as set up on Multiplatform Projects:

<?xml version="1.0" encoding="UTF-8"?>
<!-- FieldNotes/FieldNotes-macOS.entitlements -->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>com.apple.security.app-sandbox</key>
    <true/>
    <key>com.apple.security.network.client</key>                 <!-- sync with the API -->
    <true/>
    <key>com.apple.security.files.user-selected.read-write</key> <!-- notebook import and export -->
    <true/>
    <key>com.apple.security.personal-information.location</key>   <!-- location tagging -->
    <true/>
    <key>com.apple.security.application-groups</key>
    <array>
        <string>group.com.example.fieldnotes</string>
    </array>
    <key>com.apple.developer.applesignin</key>
    <array>
        <string>Default</string>
    </array>
    <key>com.apple.developer.associated-domains</key>
    <array>
        <string>webcredentials:fieldnotes.example.com</string>
        <string>applinks:fieldnotes.example.com</string>
    </array>
</dict>
</plist>

The rule is least privilege: add an entitlement when a feature needs it and remove it when the feature goes. App Review asks about unusual entitlements, and each one widens what an attacker gets if the app is compromised. Distributing outside the Mac App Store adds the hardened runtime and notarization, covered on Distributing Mac Apps with Developer ID and Notarization.

Privacy Manifests and Required-Reason APIs

A privacy manifest, PrivacyInfo.xcprivacy, is a property list in the app bundle (and in each third-party SDK) that declares four things: whether the app tracks people, the tracking domains it contacts, the types of data it collects and why, and the required-reason APIs it calls. Xcode merges the app’s manifest with its SDKs' manifests into a privacy report that you use to fill in the App Store privacy "nutrition label".

Required-reason APIs are APIs that could be used to fingerprint a device, grouped into five categories: user defaults, file timestamps, system boot time, disk space and active keyboards. Calling one without declaring an approved reason code gets the build rejected at upload. FieldNotes calls two of them:

  • UserDefaults, both its own and the App Group suite UserDefaults.fieldNotesShared;

  • file timestamps, when it sorts attachments by modification date inside its containers.

<?xml version="1.0" encoding="UTF-8"?>
<!-- FieldNotes/PrivacyInfo.xcprivacy -->
<!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>NSPrivacyTrackingDomains</key>
    <array/>
    <key>NSPrivacyCollectedDataTypes</key>
    <array>
        <dict>
            <key>NSPrivacyCollectedDataType</key>
            <string>NSPrivacyCollectedDataTypePreciseLocation</string>
            <key>NSPrivacyCollectedDataTypeLinked</key>
            <true/>
            <key>NSPrivacyCollectedDataTypeTracking</key>
            <false/>
            <key>NSPrivacyCollectedDataTypePurposes</key>
            <array>
                <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
            </array>
        </dict>
    </array>
    <key>NSPrivacyAccessedAPITypes</key>
    <array>
        <dict>
            <key>NSPrivacyAccessedAPIType</key>
            <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
            <key>NSPrivacyAccessedAPITypeReasons</key>
            <array>
                <string>CA92.1</string>   <!-- defaults only this app reads and writes -->
                <string>1C8F.1</string>   <!-- defaults shared within the App Group -->
            </array>
        </dict>
        <dict>
            <key>NSPrivacyAccessedAPIType</key>
            <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
            <key>NSPrivacyAccessedAPITypeReasons</key>
            <array>
                <string>C617.1</string>   <!-- files inside the app, App Group or CloudKit container -->
            </array>
        </dict>
    </array>
</dict>
</plist>

Precise location is "linked" because notes are tied to the account and synced. It is not tracking, because FieldNotes never combines it with data from other companies. Each reason code has a precise meaning, and the reason must be true: CA92.1, for example, forbids reading defaults that other apps wrote. The required-reason declarations apply on iOS, iPadOS, tvOS, visionOS and watchOS; a Mac app’s manifest still declares its collected data types. Third-party SDKs on Apple’s list of commonly used SDKs must ship their own signed manifest.

App Tracking Transparency

Tracking means linking data from this app with data from other companies' apps or websites for advertising or measurement, or sharing it with a data broker. It requires the person’s permission through App Tracking Transparency (ATT), and the advertising identifier (IDFA) is all zeros until they grant it. FieldNotes does not track, so it never shows the prompt and its manifest says NSPrivacyTracking = false. If a future version adds an advertising SDK, the request looks like this:

#if canImport(AppTrackingTransparency)
import AppTrackingTransparency

@MainActor
func requestTrackingIfNeeded() async {
    guard ATTrackingManager.trackingAuthorizationStatus == .notDetermined else { return }
    let status = await ATTrackingManager.requestTrackingAuthorization()
    if status != .authorized {
        // Keep ad SDKs in their non-personalized mode.
    }
}
#endif

Needs the NSUserTrackingUsageDescription key in Info.plist; without it the call crashes. Ask only while the app is active, and after explaining the value in the app’s own UI; the system shows the prompt once. ATT is not available on watchOS, hence the canImport guard.

App Attest and DeviceCheck

The FieldNotes server cannot trust the app’s own claims about itself: a modified copy can say anything. App Attest lets the server verify that a request comes from a genuine, unmodified FieldNotes on real Apple hardware. The app generates a key in the Secure Enclave, asks Apple to attest it, sends the attestation to its server once, and afterwards signs sensitive requests (sync, sharing a note, redeeming a subscription offer) with assertions. DCDevice, the older half of the DeviceCheck framework, gives two bits of per-device state that survive reinstalls, useful for "one free trial per device".

// FieldNotesServices/Security/AppIntegrity.swift
#if canImport(DeviceCheck)
import CryptoKit
import DeviceCheck
import Foundation

actor AppIntegrity {
    private let service = DCAppAttestService.shared
    private let keychain = KeychainStore()

    /// Once per install: create and attest a key. Returns nil where App Attest is unavailable.
    func attestIfNeeded(serverChallenge: Data) async throws -> (keyID: String, attestation: Data)? {
        guard service.isSupported, try keychain.data(for: "app-attest-key") == nil else { return nil }
        let keyID = try await service.generateKey()
        let hash = Data(SHA256.hash(data: serverChallenge))
        let attestation = try await service.attestKey(keyID, clientDataHash: hash)
        try keychain.set(Data(keyID.utf8), for: "app-attest-key")
        return (keyID, attestation)       // the server verifies it and stores the public key
    }

    /// For each sensitive request: sign the request body plus a fresh server challenge.
    func assertion(for requestBody: Data, challenge: Data) async throws -> Data? {
        guard let stored = try keychain.data(for: "app-attest-key") else { return nil }
        let keyID = String(decoding: stored, as: UTF8.self)
        let clientDataHash = Data(SHA256.hash(data: requestBody + challenge))
        return try await service.generateAssertion(keyID, clientDataHash: clientDataHash)
    }
}
#endif

Needs the App Attest capability (com.apple.developer.devicecheck.appattest-environment, development or production), an App ID registered with a paid developer account, and a physical device; the simulator reports isSupported == false. Treat App Attest as a risk signal, not a gate: when isSupported is false, the server should fall back to other checks instead of refusing service. With the 27 releases App Attest also covers Mac apps; FieldNotes’s code is the same on every platform because it checks isSupported at run time.

// DeviceCheck's two per-device bits: the server stores them via Apple, keyed by this token.
let token = try await DCDevice.current.generateToken()

Trust Insights (27)

Some attacks don’t break the software at all. A scammer on the phone talks the person into sharing their notes, changing the account email or sending a payment. The app sees a genuine user doing genuine actions. Trust Insights, a framework new in iOS and iPadOS 27, asks the system for an on-device, privacy-preserving evaluation of whether the person is likely being coached right now. The app then adds friction, such as a delay, a confirmation or a second factor, only when the risk is elevated.

An evaluation has two parts: an InsightEvaluator.InsightContext, which names the operation category (.account, .communication, .payment, .resourceUse or .other) and the insights requested, and the evaluator that runs it. The person must first allow the app to use Trust Insights. After acting on the result, the app reports how it used it, which improves the model.

FieldNotes asks before changing the account’s email address, the classic step in an account takeover:

// FieldNotesServices/Security/AccountChangeGuard.swift
#if canImport(TrustInsights)
import TrustInsights

@available(iOS 27, *)
enum AccountChangeGuard {
    /// true when FieldNotes should add an extra confirmation step.
    static func needsExtraFriction() async -> Bool {
        let context = InsightEvaluator.InsightContext(
            operationCategory: .account,
            requestedEvaluations: IsLikelyBeingCoachedInsight.request(schema: .version1))
        let evaluator = InsightEvaluator()
        do {
            switch try await evaluator.authorizationStatus(for: context) {
            case .authorized:
                break
            case .notDetermined, .deniedRequestable:
                guard try await evaluator.requestAuthorization(for: context) == .authorized else { return false }
            default:
                return false
            }
            let evaluation = try await evaluator.requestEvaluation(context: context)
            switch try evaluation.insight.outcome.get() {
            case .medium, .high:
                evaluation.reportConsumption(.usedIncreasedFriction)
                return true
            default:
                evaluation.reportConsumption(.usedReducedFriction)
                return false
            }
        } catch {
            return false
        }
    }
}
#endif

// At the call site, the deployment minimum is 26:
// if #available(iOS 27, *), await AccountChangeGuard.needsExtraFriction() { showCoolingOffSheet = true }

Needs the Trust Insights capability (com.apple.developer.trustinsights.base) and a device running iOS or iPadOS 27. The #if canImport(TrustInsights) guard keeps the macOS, watchOS and visionOS builds compiling, and @available(iOS 27, *) keeps the iOS 26 build compiling. The evaluation never says who is coaching or why; treat it like App Attest, as one risk signal among several.

Checklist

  • Secrets live in the Keychain, never in UserDefaults, files, Info.plist, build settings or the source (see Xcode Projects and Previews).

  • Each Keychain item uses the most restrictive accessibility class its readers allow, and ThisDeviceOnly for device-bound secrets.

  • Biometrics protect data through SecAccessControl, not only a UI overlay.

  • Sign-in offers Sign in with Apple and passkeys; the server verifies every token, nonce, challenge and signature.

  • Every entitlement in the .entitlements files is used by a shipping feature.

  • PrivacyInfo.xcprivacy lists every collected data type and every required-reason API with a true reason.

  • No tracking without ATT consent.

  • Sensitive server calls carry an App Attest assertion where supported, and risky account changes consult Trust Insights on 27.

References