```html How to Build a Two-Factor Authenticator App in SwiftUI (2026)

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.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Intermediate Estimated time: 1 week Updated: May 12, 2026

Prerequisites

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

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.

```