Storing Sensitive Data in the Keychain: Passwords, Tokens, and Secrets


A security researcher opens the binary of a popular streaming app in a hex editor and finds apiKey = "sk-pixar-prod-XXXX" sitting in plaintext. Another tool dumps the simulator’s container to disk and reveals sessionToken stored verbatim in UserDefaults.plist. The token is still valid. The user has no idea.

This post is for engineers who want to stop making that mistake. We’ll build a production-grade KeychainManager, cover access control flags, add biometric authentication, and address the gotchas that trip people up in testing and across app reinstalls. We won’t cover certificate pinning or App Attest — those are in the App Security deep-dive.

Contents

The Problem: Credentials in the Wrong Place

Here’s what a lot of iOS codebases look like when you pull them up:

// ❌ Session token stored in UserDefaults — readable in simulator file system,
//    included in unencrypted iTunes backups on non-password-protected Macs,
//    and potentially logged by analytics frameworks that snapshot UserDefaults.
UserDefaults.standard.set(authToken, forKey: "session_token")
let token = UserDefaults.standard.string(forKey: "session_token")

// ❌ API key hardcoded in source — visible in the compiled binary via
//    `strings YourApp.app/YourApp | grep sk-` or a disassembler.
let pixarAPIKey = "sk-pixar-prod-abc123xyz"

// ❌ Credentials written to a file in the Documents directory —
//    included in iTunes backups, readable if the device is jailbroken.
let credentialsPath = FileManager.default
    .urls(for: .documentDirectory, in: .userDomainMask)[0]
    .appendingPathComponent("credentials.json")
try encoder.encode(credentials).write(to: credentialsPath)

Each of these patterns exposes credentials in ways that are recoverable without device-level compromise. UserDefaults is stored as a plaintext plist in the app’s container. The Documents directory is backed up. Hardcoded strings survive app store submission and show up in disassembly tools.

The iOS Keychain is designed for exactly this data. It is encrypted at rest using hardware-backed keys, protected by the device passcode, and — critically — it is not included in unencrypted backups.

Apple Docs: Keychain Services — Security framework

The Keychain Security API

The raw Keychain API is a C interface that communicates via CFDictionary and returns OSStatus codes. It is not pleasant. Every operation — read, write, update, delete — follows the same pattern: build a query dictionary, call a SecItem* function, check the status code.

The four functions you’ll use:

Working with this API directly in application code is error-prone. The CFDictionary keys are untyped, status code handling is manual, and the update-or-insert pattern must be implemented by the caller. Building a wrapper pays for itself immediately.

Building a Type-Safe Swift Wrapper

First, define a typed error enum to replace raw OSStatus values:

import Foundation
import Security

enum KeychainError: Error, LocalizedError {
    case itemNotFound
    case duplicateItem
    case unexpectedData
    case unhandledError(status: OSStatus)

    var errorDescription: String? {
        switch self {
        case .itemNotFound:
            return "The requested Keychain item does not exist."
        case .duplicateItem:
            return "A Keychain item with this service/account already exists."
        case .unexpectedData:
            return "The Keychain item data could not be decoded."
        case .unhandledError(let status):
            return "Keychain operation failed with OSStatus \(status)."
        }
    }
}

Now the manager itself. service maps to your app’s bundle identifier or a logical grouping (e.g., "com.pixar.tracker.auth"). account is the identifier for a specific credential (e.g., "session_token" or "user@example.com").

struct KeychainManager {

    // MARK: - Save

    static func save(_ data: Data, service: String, account: String) throws {
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: account,
            kSecValueData: data
        ]

        let status = SecItemAdd(query as CFDictionary, nil)

        switch status {
        case errSecSuccess:
            break // Item created successfully.
        case errSecDuplicateItem:
            // Item already exists — update it instead.
            try update(data, service: service, account: account)
        default:
            throw KeychainError.unhandledError(status: status)
        }
    }

    // MARK: - Read

    static func read(service: String, account: String) throws -> Data {
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: account,
            kSecReturnData: true,        // Ask for the raw data back.
            kSecMatchLimit: kSecMatchLimitOne  // Return at most one result.
        ]

        var result: AnyObject?
        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:
            throw KeychainError.itemNotFound
        default:
            throw KeychainError.unhandledError(status: status)
        }
    }

    // MARK: - Update

    static func update(_ data: Data, service: String, account: String) throws {
        // The query identifies which item to update.
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: account
        ]

        // attributesToUpdate contains only the values to change.
        let attributesToUpdate: [CFString: Any] = [kSecValueData: data]

        let status = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary)

        guard status == errSecSuccess else {
            throw KeychainError.unhandledError(status: status)
        }
    }

    // MARK: - Delete

    static func delete(service: String, account: String) throws {
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: account
        ]

        let status = SecItemDelete(query as CFDictionary)

        // errSecItemNotFound is acceptable on delete — treat as a no-op.
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.unhandledError(status: status)
        }
    }
}

Usage is now clean and type-safe:

let service = "com.pixar.tracker"

// Save a session token
let tokenData = Data("eyJhbGciOiJIUzI1NiJ9...".utf8)
try KeychainManager.save(tokenData, service: service, account: "session_token")

// Read it back
let storedData = try KeychainManager.read(service: service, account: "session_token")
let token = String(data: storedData, encoding: .utf8)

// Clean up on sign-out
try KeychainManager.delete(service: service, account: "session_token")

Codable Convenience Extension

Most of the time, you’re not storing raw Data — you’re storing typed credentials. A Codable extension eliminates the encode/decode boilerplate at every call site:

extension KeychainManager {

    static func save<T: Encodable>(
        _ value: T,
        service: String,
        account: String,
        encoder: JSONEncoder = JSONEncoder()
    ) throws {
        let data = try encoder.encode(value)
        try save(data, service: service, account: account)
    }

    static func read<T: Decodable>(
        service: String,
        account: String,
        decoder: JSONDecoder = JSONDecoder()
    ) throws -> T {
        let data = try read(service: service, account: account)
        return try decoder.decode(T.self, from: data)
    }
}

Now you can store a typed credentials struct directly:

struct PixarAuthCredentials: Codable {
    let accessToken: String
    let refreshToken: String
    let expiresAt: Date
    let userID: String
}

// Save structured credentials — no manual JSON encoding at the call site.
let credentials = PixarAuthCredentials(
    accessToken: "eyJhbGci...",
    refreshToken: "dGhpcyBp...",
    expiresAt: Date().addingTimeInterval(3600),
    userID: "buzz-lightyear-42"
)

try KeychainManager.save(credentials, service: "com.pixar.tracker", account: "auth")

// Read them back with full type inference.
let stored: PixarAuthCredentials = try KeychainManager.read(
    service: "com.pixar.tracker",
    account: "auth"
)

Access Control Flags

The kSecAttrAccessible attribute controls when the Keychain item can be read. The right value depends on your app’s requirements:

AccessibilityWhen accessibleUse case
kSecAttrAccessibleAfterFirstUnlockAfter first device unlock since bootBackground app refresh, push notification handling
kSecAttrAccessibleWhenUnlockedOnly while device is unlockedCredentials only needed in foreground
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnlyOnly while unlocked AND a passcode is set; not migrated to new deviceHigh-security tokens where device transfer is unacceptable
kSecAttrAccessibleAlwaysThisDeviceOnlyAt all times, but not migrated (deprecated since iOS 12)Not recommended — available without authentication

Apple Docs: kSecAttrAccessible — Security framework

Add the accessibility attribute to your save query:

static func save(
    _ data: Data,
    service: String,
    account: String,
    accessibility: CFString = kSecAttrAccessibleAfterFirstUnlock
) throws {
    let query: [CFString: Any] = [
        kSecClass: kSecClassGenericPassword,
        kSecAttrService: service,
        kSecAttrAccount: account,
        kSecValueData: data,
        kSecAttrAccessible: accessibility  // ← Accessibility class
    ]

    let status = SecItemAdd(query as CFDictionary, nil)

    switch status {
    case errSecSuccess: break
    case errSecDuplicateItem:
        try update(data, service: service, account: account)
    default:
        throw KeychainError.unhandledError(status: status)
    }
}

For most session tokens, kSecAttrAccessibleAfterFirstUnlock is the right choice — it allows background operations (like a silent token refresh) while still encrypting the data when the device is off.

Warning: Avoid kSecAttrAccessibleAlways. It stores the item in a state that can be read even before the first unlock, which means it can be accessed on a device that has never been authenticated. Use it only when you fully understand the security trade-off.

Biometric Authentication

For high-sensitivity items — private keys, admin credentials, payment tokens — you can require Face ID or Touch ID before the item can be read. This uses SecAccessControl combined with a LAContext.

import LocalAuthentication

// MARK: - Saving a biometrically protected item

static func saveBiometric(
    _ data: Data,
    service: String,
    account: String
) throws {
    // biometryCurrentSet means the item is invalidated if the enrolled
    // biometry set changes (e.g., a new fingerprint is added).
    // Use .biometryAny to allow re-enrollment without re-prompting.
    var error: Unmanaged<CFError>?
    guard let accessControl = SecAccessControlCreateWithFlags(
        kCFAllocatorDefault,
        kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
        .biometryCurrentSet,  // Requires biometric authentication to read.
        &error
    ) else {
        throw error!.takeRetainedValue() as Error
    }

    let query: [CFString: Any] = [
        kSecClass: kSecClassGenericPassword,
        kSecAttrService: service,
        kSecAttrAccount: account,
        kSecValueData: data,
        kSecAttrAccessControl: accessControl
    ]

    let status = SecItemAdd(query as CFDictionary, nil)

    guard status == errSecSuccess || status == errSecDuplicateItem else {
        throw KeychainError.unhandledError(status: status)
    }
}

// MARK: - Reading a biometrically protected item

static func readBiometric(
    service: String,
    account: String,
    reason: String
) async throws -> Data {
    // Evaluate the policy before accessing the item.
    // This is where Face ID / Touch ID is presented to the user.
    let context = LAContext()
    try await context.evaluatePolicy(
        .deviceOwnerAuthenticationWithBiometrics,
        localizedReason: reason  // Shown in the Face ID / Touch ID prompt.
    )

    let query: [CFString: Any] = [
        kSecClass: kSecClassGenericPassword,
        kSecAttrService: service,
        kSecAttrAccount: account,
        kSecReturnData: true,
        kSecMatchLimit: kSecMatchLimitOne,
        kSecUseAuthenticationContext: context  // Pass the evaluated context.
    ]

    var result: AnyObject?
    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:
        throw KeychainError.itemNotFound
    default:
        throw KeychainError.unhandledError(status: status)
    }
}

Call the biometric read from an async context — the LAContext.evaluatePolicy call presents the system Face ID prompt and suspends until the user authenticates or cancels:

let sensitiveData = try await KeychainManager.readBiometric(
    service: "com.pixar.tracker",
    account: "admin_token",
    reason: "Authenticate to access your Pixar Studio credentials"
)

Apple Docs: SecAccessControlCreateWithFlags — Security | LAContext — Local Authentication

Advanced Usage

Keychain Groups: Sharing Between Apps

If you have multiple apps — say, Pixar Tracker and Pixar Tracker Widget — and you need to share a session token between them, use a Keychain access group. Both apps must share the same App ID prefix and have the Keychain Sharing capability enabled in their entitlements.

let sharedQuery: [CFString: Any] = [
    kSecClass: kSecClassGenericPassword,
    kSecAttrService: "com.pixar.tracker",
    kSecAttrAccount: "session_token",
    kSecAttrAccessGroup: "TEAMID.com.pixar.shared",  // ← Shared group
    kSecReturnData: true,
    kSecMatchLimit: kSecMatchLimitOne
]

Apple Docs: Sharing Access to Keychain Items Among a Collection of Apps — Security

iCloud Keychain Sync

Items can be synced across the user’s devices via iCloud Keychain by adding kSecAttrSynchronizable:

let syncedQuery: [CFString: Any] = [
    kSecClass: kSecClassGenericPassword,
    kSecAttrService: service,
    kSecAttrAccount: account,
    kSecValueData: data,
    kSecAttrSynchronizable: kCFBooleanTrue  // ← Synced via iCloud Keychain
]

Note the trade-off: synchronizable items are subject to iCloud backup and sync policies. Items protected with ThisDeviceOnly access classes are not eligible for syncing. If you need cloud-synced credentials, use kSecAttrAccessibleAfterFirstUnlock (without ThisDeviceOnly).

The @Keychain Property Wrapper

For teams that prefer property-wrapper ergonomics, a simple generic wrapper brings Keychain access inline with property declarations:

@propertyWrapper
struct Keychain<T: Codable> {
    let service: String
    let account: String

    var wrappedValue: T? {
        get { try? KeychainManager.read(service: service, account: account) }
        set {
            if let newValue {
                try? KeychainManager.save(newValue, service: service, account: account)
            } else {
                try? KeychainManager.delete(service: service, account: account)
            }
        }
    }
}

// Usage — Keychain access reads and writes exactly like a property.
final class AuthRepository {
    @Keychain(service: "com.pixar.tracker", account: "credentials")
    var credentials: PixarAuthCredentials?
}

This is convenient but suppresses errors via try?. For production use, provide a throwing variant or make error handling explicit in the wrapper.

Keychain in Tests: Avoiding Pollution

Keychain items persist between test runs unless explicitly deleted. A test that saves a session token will find it on the next run — which might cause false positives or obscure failures.

The cleanest approach is to prefix account keys with a test identifier and delete them in tearDown:

final class AuthRepositoryTests: XCTestCase {
    let testAccount = "test.\(UUID().uuidString).session_token"
    let service = "com.pixar.tracker"

    override func tearDown() async throws {
        try? KeychainManager.delete(service: service, account: testAccount)
        try await super.tearDown()
    }

    func testSaveAndReadCredentials() throws {
        let credentials = PixarAuthCredentials(
            accessToken: "test-token",
            refreshToken: "test-refresh",
            expiresAt: Date().addingTimeInterval(3600),
            userID: "woody-1"
        )

        try KeychainManager.save(credentials, service: service, account: testAccount)
        let stored: PixarAuthCredentials = try KeychainManager.read(
            service: service,
            account: testAccount
        )

        XCTAssertEqual(stored.accessToken, "test-token")
    }
}

App Reinstall: The Persistence Surprise

This surprises many engineers: Keychain items survive app deletion. If a user deletes and reinstalls your app, any Keychain items saved under your service identifier will still be there.

This has two implications:

  1. On first launch after a fresh install, check for stale tokens from a previous installation and handle them appropriately — they may refer to accounts the user has since deleted.
  2. Never assume Keychain absence means first launch. Use a separate UserDefaults flag (or a deliberately-deleted Keychain entry) to track first-launch state.
// On app launch, detect and handle tokens from previous installations.
func handleAppLaunch() {
    let isFirstLaunch = !UserDefaults.standard.bool(forKey: "hasLaunchedBefore")

    if isFirstLaunch {
        // Clear any Keychain items from a previous installation.
        // Otherwise the user might be "auto-logged-in" unexpectedly.
        try? KeychainManager.delete(service: "com.pixar.tracker", account: "credentials")
        UserDefaults.standard.set(true, forKey: "hasLaunchedBefore")
    }
}

Warning: Keychain items persist through app deletion. Always handle the case where stale credentials from a previous install are present on a fresh launch. Failing to do so can result in users being logged into the wrong account, or accessing data from a deleted account.

When to Use (and When Not To)

Data typeStorage recommendation
Session tokens, OAuth tokensKeychain — always
API keys (server-to-server)Never in the binary or on device
API keys (device-specific)Keychain, fetched at runtime from your server
User passwordsKeychain with kSecClassGenericPassword
Payment tokens / card dataKeychain + biometric access control
User preferences (theme, language)UserDefaults — not sensitive
Non-sensitive cached dataUserDefaults or file system
Large encrypted filesFile system with Data.WritingOptions.completeFileProtection
Cross-device shared secretsiCloud Keychain (kSecAttrSynchronizable)

Summary

  • UserDefaults is a plaintext plist — never use it for tokens, passwords, or API keys.
  • The raw Keychain API is a C dictionary interface; wrap it in a typed Swift struct to prevent misuse.
  • kSecAttrAccessible controls when items can be read — kSecAttrAccessibleAfterFirstUnlock is the right default for credentials that need to work in the background.
  • SecAccessControl with .biometryCurrentSet gates item access behind Face ID or Touch ID.
  • Keychain items survive app deletion — handle stale credentials on first launch.
  • Keychain items can be shared between apps via access groups and synced across devices via kSecAttrSynchronizable.
  • In tests, use unique account identifiers and delete items in tearDown to prevent test pollution.

Now that your credentials are stored safely, the next layer of defense is what happens when they travel over the network — certificate pinning and transport security are covered in the App Security deep-dive.