How to Build a Two-Factor Authenticator App in SwiftUI
A two-factor authenticator app generates time-based one-time passwords (TOTP) so users can log into websites without relying on SMS codes. It's a privacy-focused utility that attracts security-conscious iOS users — a small, well-executed niche on the App Store.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- A physical iPhone for camera-based QR code scanning (Simulator doesn't expose AVCaptureSession)
- Familiarity with RFC 6238 TOTP is helpful but not required
Architecture overview
The app stores TOTP account entries in SwiftData, each holding a Base32-encoded secret alongside metadata (label, issuer, digits, period). A stateless TOTPGenerator service uses CryptoKit's HMAC-SHA1 to compute RFC 6238 codes — no network, no server, fully local. A Timer publisher drives a 1-second refresh cycle so every row shows a live countdown ring. A QR-scanning sheet wraps AVCaptureSession via a UIViewRepresentable to parse otpauth:// URIs on import.
TwoFactorAuthApp/ ├── Models/ │ └── TOTPAccount.swift # @Model — secret, label, issuer ├── Views/ │ ├── AccountListView.swift # list + 1s timer │ ├── TOTPRowView.swift # code + countdown ring │ └── QRScannerView.swift # AVCaptureSession wrapper ├── Services/ │ └── TOTPGenerator.swift # CryptoKit HMAC-SHA1 └── PrivacyInfo.xcprivacy # camera + no tracking
Step-by-step
1. Data model
Define a SwiftData @Model that persists each TOTP account — the Base32 secret never leaves the device and is the only sensitive field to later migrate to Keychain in a production hardening pass.
import SwiftData
import Foundation
@Model
final class TOTPAccount {
var id: UUID
var label: String // e.g. "alice@example.com"
var issuer: String // e.g. "GitHub"
var secret: String // Base32-encoded shared secret
var digits: Int // 6 or 8
var period: Int // 30 or 60 seconds
var createdAt: Date
init(label: String, issuer: String, secret: String,
digits: Int = 6, period: Int = 30) {
self.id = UUID()
self.label = label
self.issuer = issuer
self.secret = secret.uppercased()
self.digits = digits
self.period = period
self.createdAt = .now
}
}
2. Core UI — account list
Drive every row from a single @State var now: Date refreshed by a 1-second timer so all countdown rings stay in sync without each row managing its own timer.
struct AccountListView: View {
@Environment(\.modelContext) private var context
@Query(sort: \TOTPAccount.issuer) private var accounts: [TOTPAccount]
@State private var now: Date = .now
@State private var showScanner = false
private let ticker = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
NavigationStack {
List(accounts) { account in
TOTPRowView(account: account, now: now)
.swipeActions { Button("Delete", role: .destructive) {
context.delete(account)
}}
}
.navigationTitle("Authenticator")
.toolbar {
Button { showScanner = true } label: {
Image(systemName: "qrcode.viewfinder")
}
}
.sheet(isPresented: $showScanner) { QRScannerView() }
.onReceive(ticker) { now = $0 }
}
}
}
3. TOTP code generation (CryptoKit)
RFC 6238 reduces to: decode the Base32 secret → build an 8-byte big-endian counter from floor(unixTime / period) → compute HMAC-SHA1 → dynamic truncate to N digits.
import CryptoKit
import Foundation
enum TOTPGenerator {
static func code(for account: TOTPAccount, at date: Date = .now) -> String {
guard let secretData = Base32.decode(account.secret) else { return "------" }
var counter = UInt64(date.timeIntervalSince1970 / Double(account.period)).bigEndian
let msg = Data(bytes: &counter, count: 8)
let key = SymmetricKey(data: secretData)
let mac = HMAC<Insecure.SHA1>.authenticationCode(for: msg, using: key)
let bytes = Array(mac)
let offset = Int(bytes[19] & 0x0f)
let bin = (UInt32(bytes[offset] & 0x7f) << 24)
| (UInt32(bytes[offset+1]) << 16)
| (UInt32(bytes[offset+2]) << 8)
| UInt32(bytes[offset+3])
let otp = bin % UInt32(pow(10.0, Double(account.digits)))
return String(format: "%0\(account.digits)d", otp)
}
static func progress(for account: TOTPAccount, at date: Date = .now) -> Double {
let elapsed = date.timeIntervalSince1970
.truncatingRemainder(dividingBy: Double(account.period))
return 1.0 - (elapsed / Double(account.period))
}
}
4. Privacy Manifest (PrivacyInfo.xcprivacy)
App Store Connect will reject your build without a Privacy Manifest; add PrivacyInfo.xcprivacy to your app target declaring camera use and confirming no tracking domains.
<?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>
Common pitfalls
- Storing secrets in SwiftData plain text. SwiftData doesn't encrypt at rest by default. In production, store each
secretin the iOS Keychain and only keep a non-sensitive reference ID in the@Model. - Wrong Base32 alphabet or missing padding. RFC 4648 Base32 uses A–Z + 2–7, not standard Base64. Many online snippets silently drop padding — your HMAC input will be wrong and all codes will fail.
- SHA-256 instead of SHA-1. Standard TOTP (RFC 6238) defaults to HMAC-SHA1. Most sites issue SHA-1 secrets; using
HMAC<SHA256>produces valid but incompatible codes that will always be rejected. - Missing NSCameraUsageDescription. App Store review will reject any binary that calls
AVCaptureSessionwithout the camera usage string inInfo.plist— even if the user never taps the scanner. - Clock drift on device. TOTP is time-sensitive. If the user's device clock is off by more than 30 seconds, codes fail. Show a subtle warning if
abs(Date().timeIntervalSinceNow - serverTime) > 15using a lightweight NTP check at launch.
Adding monetization: One-time purchase
Use StoreKit 2's Product.purchase() API to gate a "Pro" tier — typically unlimited accounts or iCloud sync — behind a one-time non-consumable IAP. Declare the product in App Store Connect, then call await Product.products(for: ["com.yourapp.pro"]) at launch and verify Transaction.currentEntitlement(for:) on each app foreground. StoreKit 2's async/await API is far simpler than the legacy SKPaymentQueue; no receipt validation server is needed since the framework validates locally. Gate the UI with a simple @AppStorage("isPro") var isPro = false that you flip after a verified transaction, and always restore purchases from the Settings sheet so users who reinstall aren't charged twice.
Shipping this faster with Soarias
Soarias scaffolds the full SwiftData + CryptoKit project in seconds — @Model, model container wiring, and a working TOTPGenerator stub are all in place before you write a line of business logic. It also auto-generates the PrivacyInfo.xcprivacy with the correct camera and UserDefaults API type entries, sets up fastlane with match for code signing, and submits to App Store Connect including screenshots sized for every device class.
For an intermediate project like this, most developers spend 2–3 days on Xcode project setup, certificates, fastlane config, and ASC metadata. Soarias compresses that to under an hour, so your week is spent on the actual TOTP logic, QR scanning, and Keychain hardening — the parts that make your app defensible — not boilerplate.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. A free account lets you run the app on your own device via Xcode, but TestFlight distribution and App Store submission both require the $99/year Apple Developer Program membership.
How do I submit this to the App Store?
Archive the app in Xcode (Product → Archive), upload via Xcode Organizer or xcrun altool, then complete the App Store Connect listing — screenshots, privacy labels, and age rating. Apple's review for a first-time authenticator app typically takes 24–48 hours; expect a question about the camera entitlement if your reviewer tests on Simulator.
Can I add iCloud sync so users keep codes across devices?
Yes, but with caution. Enable the iCloud capability and swap ModelContainer to use CloudKit syncing. You must also encrypt secrets before they reach CloudKit — store encrypted blobs and derive a device-local encryption key from the Secure Enclave so raw secrets are never written to iCloud in plaintext.
Last reviewed: 2026-05-12 by the Soarias team.