```html How to Build a Password Manager App in SwiftUI (2026)

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.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Advanced Estimated time: 2–4 weeks Updated: May 12, 2026

Prerequisites

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

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.

```