How to Build Encrypted Storage in SwiftUI
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
-
Key derivation & Keychain persistence —
symmetricKey()first attempts to load an existing 256-bit key from the Keychain viakSecClassGenericPassword. If none exists it callsSymmetricKey(size: .bits256)to generate a cryptographically secure random key, serialises it toDatawithwithUnsafeBytes, and saves it. Subsequent launches always retrieve the same key, so previously-encrypted blobs remain readable. -
AES-GCM sealing —
AES.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 singleDatablob — safe to write to any storage layer (Keychain, disk, CloudKit). -
Opening (decryption + verification) —
AES.GCM.SealedBox(combined:)re-splits the blob into nonce / ciphertext / tag, thenAES.GCM.open(_:using:)decrypts and verifies the tag in one step. If even a single bit of the ciphertext was tampered with, CryptoKit throws anCryptoKit.CryptoKitError.authenticationFailure— you never get back corrupt plaintext silently. -
Delete-before-add pattern —
KeychainHelper.savecallsSecItemDeletebeforeSecItemAdd. Without this, updating an existing Keychain item returnserrSecDuplicateItem(-25299). This pattern keeps the helper simple; for high-frequency writes preferSecItemUpdateto avoid the double round-trip. -
SwiftUI error propagation — Both
saveandloadare throwing functions. The view catches errors into@State var errorMsgand displays them using aLabelwith 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
-
iOS version floor:
AES.GCMis available from iOS 13+, butSymmetricKey(size:)with.bits256and theSealedBox(combined:)initialiser require iOS 13.2+. Always test on the minimum deployment target — not just the simulator running the latest OS. -
Forgetting
kSecAttrAccessible: By default Keychain items usekSecAttrAccessibleWhenUnlocked. If your app needs to decrypt data in a background URLSession or in a background push handler, setkSecAttrAccessibleAfterFirstUnlockThisDeviceOnlyexplicitly. Using the wrong accessibility class causes silenterrSecInteractionNotAllowedfailures at 2 AM. -
Never reuse a nonce:
AES.GCM.sealgenerates a random nonce for you — never override it with a static value. Nonce reuse with the same key completely breaks AES-GCM's confidentiality and allows an attacker to recover the plaintext XOR. -
iCloud Keychain sync:
kSecClassGenericPassworditems sync to iCloud Keychain by default unless you setkSecAttrSynchronizable: false. Syncing a device-bound encryption key means any iCloud-connected device can decrypt your local ciphertext — addkSecAttrSynchronizable: kCFBooleanFalse!to your Keychain queries if the data must stay on-device. -
VoiceOver & sensitive fields: Mark decrypted text fields with
.accessibilityHidden(true)or use.privacySensitive()(iOS 15+) so screen readers and smart invert don't expose secrets on screen recordings.
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.