How to Build a Password Manager App in SwiftUI
A Password Manager app lets users securely store, autofill, and manage credentials using an encrypted vault backed by the iOS Keychain. It's built for privacy-conscious iOS users who want a local-first alternative to cloud-dependent password managers.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Solid Swift/SwiftUI knowledge; familiarity with async/await
- Keychain entitlement enabled in your app target (Signing & Capabilities → Keychain Sharing)
- Physical iPhone for biometric testing — Face ID/Touch ID does not work in Simulator
- Understanding of symmetric encryption basics (AES-GCM) is strongly recommended
Architecture overview
The app uses SwiftData to persist non-sensitive metadata (site name, username, URL, creation date) and stores the actual plaintext password exclusively in the iOS Keychain, encrypted at rest by CryptoKit AES-GCM before it ever touches storage. A VaultStore observable object owns all state — lock status, search query, selected entry — and gates every read through a LocalAuthentication biometric challenge. StoreKit 2 manages the subscription paywall that unlocks unlimited vault entries.
PasswordManagerApp/ ├── Models/ │ ├── PasswordEntry.swift # SwiftData @Model (metadata only) │ └── VaultStore.swift # @Observable vault state + auth ├── Services/ │ ├── KeychainService.swift # SecItem CRUD wrapper │ └── CryptoService.swift # AES-GCM encrypt/decrypt ├── Views/ │ ├── VaultListView.swift # Main locked/unlocked list │ ├── EntryDetailView.swift # Edit + reveal password │ └── PaywallView.swift # StoreKit 2 subscription └── PrivacyInfo.xcprivacy
Step-by-step
1. Data model — SwiftData metadata + Keychain secret
Store only non-sensitive fields in SwiftData; the password itself never leaves the Keychain, keyed by the entry's stable UUID.
import SwiftData
import Foundation
@Model
final class PasswordEntry {
@Attribute(.unique) var id: UUID
var label: String
var username: String
var siteURL: String
var createdAt: Date
var updatedAt: Date
var isFavorite: Bool
// Password is NOT stored here — fetched from Keychain by id
init(label: String, username: String, siteURL: String) {
self.id = UUID()
self.label = label
self.username = username
self.siteURL = siteURL
self.createdAt = Date()
self.updatedAt = Date()
self.isFavorite = false
}
}
// Keychain key: "vault-\(entry.id.uuidString)"
2. Core UI — locked vault list with biometric gate
Show a lock screen until the user authenticates; once unlocked, present a searchable list of entries with swipe actions.
struct VaultListView: View {
@Environment(\.modelContext) private var ctx
@Query(sort: \PasswordEntry.label) private var entries: [PasswordEntry]
@State private var store = VaultStore()
@State private var searchText = ""
var filtered: [PasswordEntry] {
searchText.isEmpty ? entries :
entries.filter { $0.label.localizedCaseInsensitiveContains(searchText) }
}
var body: some View {
NavigationStack {
Group {
if store.isLocked {
LockScreenView { await store.authenticate() }
} else {
List(filtered) { entry in
NavigationLink(destination: EntryDetailView(entry: entry, store: store)) {
EntryRowView(entry: entry)
}
.swipeActions { Button("Delete", role: .destructive) {
store.deleteEntry(entry, context: ctx)
}}
}
.searchable(text: $searchText, prompt: "Search vault")
}
}
.navigationTitle("Vault")
.toolbar { AddEntryButton(store: store) }
}
.task { await store.authenticate() }
}
}
3. Core feature — AES-GCM encryption + Keychain storage
Encrypt the password with a per-device AES-256-GCM key derived from the Secure Enclave before writing to Keychain, and gate reads behind LocalAuthentication.
import CryptoKit
import Security
import LocalAuthentication
actor CryptoService {
private let keyTag = "com.yourapp.vault.key"
private func vaultKey() throws -> SymmetricKey {
// Load or generate a 256-bit AES key stored in Keychain
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationTag as String: keyTag,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let data = result as? Data {
return SymmetricKey(data: data)
}
let newKey = SymmetricKey(size: .bits256)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationTag as String: keyTag,
kSecValueData as String: newKey.withUnsafeBytes { Data($0) }
]
SecItemAdd(addQuery as CFDictionary, nil)
return newKey
}
func save(password: String, forID id: UUID) throws {
let key = try vaultKey()
let sealed = try AES.GCM.seal(Data(password.utf8), using: key)
let combined = sealed.combined!
let keychainKey = "vault-\(id.uuidString)" as CFString
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: keychainKey,
kSecValueData as String: combined
]
SecItemDelete(addQuery as CFDictionary) // remove old if exists
SecItemAdd(addQuery as CFDictionary, nil)
}
func load(forID id: UUID) throws -> String {
let key = try vaultKey()
let keychainKey = "vault-\(id.uuidString)" as CFString
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: keychainKey,
kSecReturnData as String: true
]
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else { throw VaultError.notFound }
let sealed = try AES.GCM.SealedBox(combined: data)
let plain = try AES.GCM.open(sealed, using: key)
return String(decoding: plain, as: UTF8.self)
}
}
4. Privacy Manifest — required for App Store submission
Any app using Keychain or LocalAuthentication APIs must declare usage reasons in PrivacyInfo.xcprivacy or App Store review will reject the build.
<!-- PrivacyInfo.xcprivacy (Property List / XML source) -->
<?xml version="1.0" encoding="UTF-8"?>
<!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/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>CA92.1</string></array>
</dict>
</array>
</dict>
</plist>
// Also add NSFaceIDUsageDescription in Info.plist:
// "We use Face ID to unlock your password vault."
Common pitfalls
- Storing passwords in SwiftData or UserDefaults. Both are readable without authentication. The Keychain — with the correct
kSecAttrAccessibleflag — is the only acceptable store for secrets on iOS. - Using
kSecAttrAccessibleAlways. This bypasses the device lock screen. UsekSecAttrAccessibleWhenUnlockedThisDeviceOnlyto prevent iCloud Keychain sync and unauthorized background access. - App Store rejection for missing NSFaceIDUsageDescription. If you call
LAContext.evaluatePolicywithout this key in Info.plist, the binary is rejected at upload time — not during review. - Keychain items persisting after app deletion. Unlike UserDefaults, Keychain items survive uninstalls by default. On first launch after a clean install, purge stale keys or your decryption will fail on re-install with a new SwiftData store.
- Simulator biometric testing gaps.
LAContextcan simulate biometrics in Simulator via Features → Face ID / Touch ID → Enrolled, but Secure Enclave key operations require a physical device. Budget time for on-device testing early.
Adding monetization: Subscription
Use StoreKit 2's Product.products(for:) to load a single auto-renewable subscription (e.g., monthly and annual tiers) that unlocks unlimited vault entries beyond a free tier of 10. Gate entry creation in VaultStore by checking Transaction.currentEntitlement(for:) — this is synchronous after the initial async load, so there's no UI flicker. Display a PaywallView sheet with StoreView(ids:) (iOS 17+) for zero-boilerplate purchase UI that Apple already approves. Always call AppStore.sync() on first launch to restore purchases after reinstall, and handle StoreKitError.userCancelled gracefully rather than showing an error alert.
Shipping this faster with Soarias
Soarias scaffolds the full Password Manager project — SwiftData model, KeychainService, CryptoService, VaultStore with LocalAuthentication, and a StoreKit 2 paywall — in a single prompt. It also generates the PrivacyInfo.xcprivacy with the correct API reason codes, configures the Keychain Sharing entitlement, and sets up fastlane with match for code signing so you never touch provisioning profiles manually. The App Store Connect metadata, screenshots (all required device sizes), and the initial submission are handled through Soarias's built-in ASC integration.
For an advanced app like this, the scaffolding and boilerplate that typically consumes the first 3–5 days — Keychain wrappers, CryptoKit setup, entitlements, Privacy Manifest, fastlane — is ready in under an hour. That compresses a realistic 3–4 week solo build to roughly 1–2 weeks of focused feature work and testing on real devices.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. You need the $99/year Apple Developer Program membership to enable the Keychain Sharing entitlement, distribute via TestFlight, and submit to the App Store. Free provisioning profiles do not support Keychain Sharing, which is required for this app.
How do I submit this to the App Store?
Archive the app in Xcode (Product → Archive), upload via the Organizer or xcrun altool/fastlane deliver, fill in App Store Connect metadata including the Privacy Nutrition Labels for Keychain usage, and submit for review. Expect 1–3 days for the initial review of a security-sensitive app category.
Can I sync the vault across devices with iCloud?
You can, but it requires care. iCloud Keychain sync is controlled by kSecAttrSynchronizable. Never sync items stored with kSecAttrAccessibleWhenUnlockedThisDeviceOnly — that flag explicitly prevents it. For cross-device sync, use kSecAttrAccessibleAfterFirstUnlock combined with iCloud CloudKit to sync an encrypted blob, and decrypt locally on each device with a per-device key exchange step.
Last reviewed: 2026-05-12 by the Soarias team.