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 |
|
Prove the person is present |
LocalAuthentication |
|
Prove who the person is |
AuthenticationServices (Sign in with Apple, passkeys) |
|
Declare and limit what the app can do |
Entitlements, App Sandbox, |
|
Platform availability
| API or feature | iOS | iPadOS | macOS | watchOS | visionOS |
|---|---|---|---|---|---|
Keychain Services ( |
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 |
|
Yes |
Yes |
Yes |
Yes |
Yes |
Passkeys ( |
Yes |
Yes |
Yes |
Not available |
Yes |
Passkey account creation, |
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 ( |
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)
}
}
}
See Apple Developer Documentation — Keychain Services, Apple Developer Documentation -- SecItemAdd(_:_:), Apple Developer Documentation -- SecItemCopyMatching(_:_:), Apple Developer Documentation -- SecItemUpdate(_:_:) and Apple Developer Documentation -- SecItemDelete(_:).
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 |
|---|---|---|
|
Device unlocked and a passcode is set; the item is deleted if the passcode is removed |
The key that |
|
Device unlocked |
Nothing yet: foreground-only secrets |
|
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)
See Apple Developer Documentation — Restricting keychain item accessibility and Apple Developer Documentation — kSecAttrAccessibleAfterFirstUnlock.
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
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>
See Apple Developer Documentation — Sharing access to keychain items among a collection of apps and Apple Developer Documentation — kSecAttrAccessGroup.
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 }
}
See Apple Developer Documentation — LAContext, Apple Developer Documentation — LABiometryType and Apple Developer Documentation — LAPolicy.deviceOwnerAuthentication.
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
}
}
See Apple Developer Documentation — Accessing Keychain Items with Face ID or Touch ID, Apple Developer Documentation -- SecAccessControlCreateWithFlags(_:_:_:_:) and Apple Developer Documentation — kSecUseAuthenticationContext.
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))
}
}
See Apple Developer Documentation — HPKE, Apple Developer Documentation — XWingMLKEM768X25519 and Apple Developer Documentation — Enhancing your app’s privacy and security with quantum-secure workflows.
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")
See Apple Developer Documentation — SecureEnclave, Apple Developer Documentation — SecureEnclave.P256.Signing.PrivateKey and Apple Developer Documentation — Storing CryptoKit Keys in the Keychain.
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 }
}
}
}
See Apple Developer Documentation — Implementing User Authentication with Sign in with Apple and Apple Developer Documentation — SignInWithAppleButton.
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)
See Apple Developer Documentation — getCredentialState(forUserID:completion:) and Human Interface Guidelines — Sign in with Apple.
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
See Apple Developer Documentation — Supporting passkeys, Apple Developer Documentation — ASAuthorizationPlatformPublicKeyCredentialProvider and Apple Developer Documentation — AuthorizationController.
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
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>
See Apple Developer Documentation — App Sandbox Entitlement, Apple Developer Documentation — Configuring the macOS App Sandbox and Apple Developer Documentation — Associated Domains Entitlement.
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 suiteUserDefaults.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>
See Apple Developer Documentation — Privacy manifest files and Apple Developer Documentation — Describing use of required reason API.
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
See Apple Developer Documentation — App Tracking Transparency and Apple Developer Documentation — requestTrackingAuthorization(completionHandler:).
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
See Apple Developer Documentation — Establishing your app’s integrity, Apple Developer Documentation — DCAppAttestService and Apple Developer Documentation — Validating apps that connect to your server.
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 }
See Apple Developer Documentation — Trust Insights and Apple Developer Documentation — InsightEvaluator.
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
ThisDeviceOnlyfor 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
.entitlementsfiles is used by a shipping feature. -
PrivacyInfo.xcprivacylists 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
-
Apple Developer Documentation — kSecUseDataProtectionKeychain
-
Apple Developer Documentation — Restricting keychain item accessibility
-
Apple Developer Documentation — kSecAttrAccessibleAfterFirstUnlock
-
Apple Developer Documentation — Sharing access to keychain items among a collection of apps
-
Apple Developer Documentation — Keychain Access Groups Entitlement
-
Apple Developer Documentation — LAPolicy.deviceOwnerAuthentication
-
Apple Developer Documentation — Accessing Keychain Items with Face ID or Touch ID
-
Apple Developer Documentation -- SecAccessControlCreateWithFlags(_:_:_:_:)
-
Apple Developer Documentation — kSecUseAuthenticationContext
-
Apple Developer Documentation — SecureEnclave.P256.Signing.PrivateKey
-
Apple Developer Documentation — Storing CryptoKit Keys in the Keychain
-
Apple Developer Documentation — Implementing User Authentication with Sign in with Apple
-
Apple Developer Documentation — getCredentialState(forUserID:completion:)
-
Apple Developer Documentation — ASAuthorizationPlatformPublicKeyCredentialProvider
-
Apple Developer Documentation — ASAuthorizationAccountCreationProvider
-
Apple Developer Documentation — Configuring the macOS App Sandbox
-
Apple Developer Documentation — Associated Domains Entitlement
-
Apple Developer Documentation — Describing use of required reason API
-
Apple Developer Documentation — requestTrackingAuthorization(completionHandler:)
-
Apple Developer Documentation — Establishing your app’s integrity
-
Apple Developer Documentation — Validating apps that connect to your server