How to Build Encrypted Storage in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: CryptoKit / Keychain Updated: May 12, 2026
TL;DR

Generate a SymmetricKey with CryptoKit, store it securely in the iOS Keychain, then use AES.GCM.seal / AES.GCM.open to encrypt and decrypt your app's sensitive data blobs.

import CryptoKit
import Security

// 1. Seal (encrypt)
func encrypt(_ plaintext: Data, using key: SymmetricKey) throws -> Data {
    let box = try AES.GCM.seal(plaintext, using: key)
    return box.combined!          // nonce + ciphertext + tag
}

// 2. Open (decrypt)
func decrypt(_ ciphertext: Data, using key: SymmetricKey) throws -> Data {
    let box = try AES.GCM.SealedBox(combined: ciphertext)
    return try AES.GCM.open(box, using: key)
}

Full implementation

The implementation below is split into two pieces: KeychainHelper manages raw Data persistence in the Keychain, and EncryptedStore ties that together with CryptoKit AES-GCM to provide a clean save/load API your SwiftUI views can call directly. The SwiftUI view wires everything up with @State and shows inline error handling so bad decrypts surface cleanly in the UI rather than crashing.

import SwiftUI
import CryptoKit
import Security

// MARK: - Keychain Helper

enum KeychainError: Error, LocalizedError {
    case unexpectedStatus(OSStatus)
    case itemNotFound

    var errorDescription: String? {
        switch self {
        case .itemNotFound:            return "No item found in Keychain."
        case .unexpectedStatus(let s): return "Keychain OSStatus \(s)."
        }
    }
}

struct KeychainHelper {
    static func save(_ data: Data, service: String, account: String) throws {
        let query: [CFString: Any] = [
            kSecClass:       kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: account,
            kSecValueData:   data
        ]
        // Delete existing item first to allow updates
        SecItemDelete(query as CFDictionary)
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.unexpectedStatus(status)
        }
    }

    static func load(service: String, account: String) throws -> Data {
        let query: [CFString: Any] = [
            kSecClass:            kSecClassGenericPassword,
            kSecAttrService:      service,
            kSecAttrAccount:      account,
            kSecReturnData:       true,
            kSecMatchLimit:       kSecMatchLimitOne
        ]
        var item: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &item)
        guard status == errSecSuccess else {
            if status == errSecItemNotFound { throw KeychainError.itemNotFound }
            throw KeychainError.unexpectedStatus(status)
        }
        return item as! Data
    }
}

// MARK: - Encrypted Store

struct EncryptedStore {
    private let keyService  = "com.yourapp.encryptedstore"
    private let keyAccount  = "symmetricKey"
    private let dataService = "com.yourapp.encryptedstore"
    private let dataAccount = "payload"

    /// Returns existing key from Keychain or generates + persists a new one.
    func symmetricKey() throws -> SymmetricKey {
        if let raw = try? KeychainHelper.load(service: keyService, account: keyAccount) {
            return SymmetricKey(data: raw)
        }
        let key = SymmetricKey(size: .bits256)
        let raw = key.withUnsafeBytes { Data($0) }
        try KeychainHelper.save(raw, service: keyService, account: keyAccount)
        return key
    }

    func save(_ string: String) throws {
        guard let plaintext = string.data(using: .utf8) else { return }
        let key  = try symmetricKey()
        let box  = try AES.GCM.seal(plaintext, using: key)
        try KeychainHelper.save(box.combined!, service: dataService, account: dataAccount)
    }

    func load() throws -> String {
        let key        = try symmetricKey()
        let combined   = try KeychainHelper.load(service: dataService, account: dataAccount)
        let box        = try AES.GCM.SealedBox(combined: combined)
        let plaintext  = try AES.GCM.open(box, using: key)
        return String(data: plaintext, encoding: .utf8) ?? ""
    }
}

// MARK: - SwiftUI View

struct EncryptedStorageView: View {
    @State private var inputText   = ""
    @State private var loadedText  = ""
    @State private var errorMsg    = ""
    @State private var store       = EncryptedStore()

    var body: some View {
        NavigationStack {
            Form {
                Section("Save Secret") {
                    TextField("Enter secret text…", text: $inputText)
                        .accessibilityLabel("Secret text input")
                    Button("Encrypt & Save") {
                        do {
                            try store.save(inputText)
                            errorMsg = ""
                        } catch {
                            errorMsg = error.localizedDescription
                        }
                    }
                    .disabled(inputText.isEmpty)
                }

                Section("Load Secret") {
                    Button("Decrypt & Load") {
                        do {
                            loadedText = try store.load()
                            errorMsg   = ""
                        } catch {
                            errorMsg   = error.localizedDescription
                        }
                    }
                    if !loadedText.isEmpty {
                        Text(loadedText)
                            .font(.body.monospaced())
                            .accessibilityLabel("Decrypted secret: \(loadedText)")
                    }
                }

                if !errorMsg.isEmpty {
                    Section {
                        Label(errorMsg, systemImage: "exclamationmark.triangle")
                            .foregroundStyle(.red)
                    }
                }
            }
            .navigationTitle("Encrypted Storage")
        }
    }
}

#Preview {
    EncryptedStorageView()
}

How it works

  1. Key derivation & Keychain persistencesymmetricKey() first attempts to load an existing 256-bit key from the Keychain via kSecClassGenericPassword. If none exists it calls SymmetricKey(size: .bits256) to generate a cryptographically secure random key, serialises it to Data with withUnsafeBytes, and saves it. Subsequent launches always retrieve the same key, so previously-encrypted blobs remain readable.
  2. AES-GCM sealingAES.GCM.seal(_:using:) wraps plaintext in an authenticated cipher: it generates a random 12-byte nonce, produces a ciphertext of the same length as the input, and appends a 16-byte authentication tag. box.combined! returns all three concatenated into a single Data blob — safe to write to any storage layer (Keychain, disk, CloudKit).
  3. Opening (decryption + verification)AES.GCM.SealedBox(combined:) re-splits the blob into nonce / ciphertext / tag, then AES.GCM.open(_:using:) decrypts and verifies the tag in one step. If even a single bit of the ciphertext was tampered with, CryptoKit throws an CryptoKit.CryptoKitError.authenticationFailure — you never get back corrupt plaintext silently.
  4. Delete-before-add patternKeychainHelper.save calls SecItemDelete before SecItemAdd. Without this, updating an existing Keychain item returns errSecDuplicateItem (-25299). This pattern keeps the helper simple; for high-frequency writes prefer SecItemUpdate to avoid the double round-trip.
  5. SwiftUI error propagation — Both save and load are throwing functions. The view catches errors into @State var errorMsg and displays them using a Label with a system image, keeping the UI responsive even when Keychain access is denied (e.g., device locked with background refresh).

Variants

Encrypt files on disk (large payloads)

For payloads too large to keep entirely in Keychain, seal the data and write the combined blob to the app's Documents directory. Only the key lives in Keychain.

import CryptoKit
import Foundation

struct DiskEncryptedFile {
    let url: URL
    let key: SymmetricKey          // sourced from Keychain (see above)

    func write(_ data: Data) throws {
        let box = try AES.GCM.seal(data, using: key)
        try box.combined!.write(to: url, options: .atomic)
    }

    func read() throws -> Data {
        let combined = try Data(contentsOf: url)
        let box      = try AES.GCM.SealedBox(combined: combined)
        return try AES.GCM.open(box, using: key)
    }
}

// Usage
let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let fileURL = docsDir.appending(path: "secret.enc")
let store   = DiskEncryptedFile(url: fileURL, key: try EncryptedStore().symmetricKey())
try store.write(Data("Large payload".utf8))
let back    = try store.read()
print(String(data: back, encoding: .utf8)!)   // "Large payload"

Derive a key from a user passphrase (PBKDF2)

When you want user-controlled encryption (no automatic device key), derive a SymmetricKey from a passphrase using HKDF<SHA256>.deriveKey(inputKeyMaterial:salt:info:outputByteCount:). Store a random salt in Keychain instead of the raw key. This means data is irrecoverable if the user forgets the passphrase — make sure to communicate that clearly in your UI. CryptoKit's HKDF conforms to RFC 5869 and is available from iOS 14+.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement encrypted storage in SwiftUI for iOS 17+.
Use CryptoKit AES.GCM for encryption and Security/Keychain
for key persistence (kSecClassGenericPassword, .bits256 key).
Add kSecAttrSynchronizable: false and kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly.
Make it accessible (VoiceOver labels, .privacySensitive()).
Add a #Preview with realistic sample data.

In the Soarias Build phase, paste this prompt directly into the implementation canvas — Soarias will scaffold KeychainHelper and EncryptedStore as separate Swift files and wire them into your existing SwiftData model layer automatically.

Related

FAQ

Does this work on iOS 16?

Yes — CryptoKit and the Keychain APIs used here are available from iOS 13+. The #Preview macro requires Xcode 15+ but can be replaced with a PreviewProvider for older toolchains. The SwiftUI NavigationStack requires iOS 16+; swap it for NavigationView if you target iOS 15 or below.

What happens to encrypted data if the user reinstalls the app?

By default, Keychain items with kSecAttrSynchronizable: false survive an app reinstall on the same device — the OS owns the Keychain, not the app sandbox. This means your symmetric key is still retrievable after reinstall and old ciphertexts stored on disk remain decryptable. If you want data wiped on reinstall, delete the Keychain item in applicationDidFinishLaunching when a first-run flag (stored in UserDefaults) is absent.

What's the UIKit equivalent?

The KeychainHelper and EncryptedStore structs are framework-agnostic Swift — they work identically in UIKit. Replace the SwiftUI Form / Button views with a UIViewController that calls store.save() and store.load() from IBAction handlers. No changes to the CryptoKit or Keychain logic are needed.

Last reviewed: 2026-05-12 by the Soarias team.