In this code-along, we’ll build a biometric authentication flow using Swift Concurrency to handle background tasks like Face ID checks and lockout timers—keeping your app responsive. It will look the following:

A Quick Introduction to Concurrency in Swift
Before we dive into building our biometric authentication flow, let’s take a moment to understand a powerful feature we’ll be using behind the scenes: Swift Concurrency.
What is Concurrency?
Concurrency means running multiple tasks seemingly at the same time — for example, loading data from the internet while keeping your app’s interface responsive. It allows you to perform work in the background (like checking Face ID) without freezing the user interface.
Why Use It?
Traditionally, developers used completion handlers and GCD (Grand Central Dispatch – using DispatchQueue) to manage background tasks. But this often led to confusing, deeply nested code (“callback hell”).
With Swift Concurrency, Apple introduced modern tools like:
- async/await: cleaner syntax to work with asynchronous code
- Task: to create units of work that run concurrently
- MainActor: to make sure UI updates happen on the main thread
These tools make your code easier to read, write, and reason about — especially when you’re juggling background tasks and UI updates.
Why Are We Using It in This Project?
In Unlockly, we use Swift Concurrency to:
- Perform biometric authentication (Face ID or Touch ID) asynchronously
- Wait for the user’s response without blocking the main thread
- Show a countdown during a lockout using modern async timing instead of old-school timers
This keeps the app smooth and responsive — while your authentication logic runs safely and clearly behind the scenes.
Let’s get started with our project!
Step 1: Set up your project
- Open Xcode: Launch Xcode and select Create a new Xcode project.
- Choose Template: Select App under the iOS tab and click Next.
- Name Your Project: Enter a name for your project, like
Unlockly
.
Choose SwiftUI for the interface and Swiftfor the language. Click Next, and then save your project.
When you open your project, you’ll see the already familiar standard code presenting a globe and the Text “Hello, world!” in the ContentView.swift
.
Before we start coding, we need to inform the system why our app wants to use Face ID or Touch ID. iOS requires this for privacy reasons: every time your app asks for biometric authentication, the system presents your custom message to the user, explaining the purpose. Here is how we add such a description:
- In the Project Navigator, click on the upper
Unlockly
app symbol. - On the right, chose the tab
Info
. - Right-click anywhere in the list and choose Add Row or the
+
symbol. - Select the key “Privacy – Face ID Usage Description” from the dropdown.
- In the value column, enter a short message like: “Please authenticate to unlock the app.”
Now we can start coding!
Step 2: Create an AuthenticationManager
Create a new SwiftUI file. Go to File - New - File from Template...
or simply press cmd + N
, choose iOS Template
and here Swift File
, click Next
, choose AuthenticationManager
as the name of the new view, ensure that Unlockly
is selected under Group
, check the tick box for Targets: Unlockly
, click Create
.
Step 2.1: Start with the imports and class
At the top of the file, import the required frameworks and define the AuthenticationManager
class:
import SwiftUI
import LocalAuthentication
We import the LocalAuthentication framework because it gives us access to biometric authentication features like Face ID, Touch ID, and device passcode fallback. Specifically, it provides the LAContext class, which we use to:
- Check whether biometric authentication is available on the device
- Prompt the user to authenticate
- Handle the result of that authentication attempt
Without this framework, we wouldn’t be able to use Apple’s built-in secure authentication mechanisms — which are critical for building a safe and user-friendly login flow.
🔐 LocalAuthentication ensures your app integrates directly with the system’s secure enclave, so you never deal with raw fingerprint or face data — just a simple yes/no authentication result.
Then create your class and mark it with @MainActor
.
@MainActor
class AuthenticationManager: ObservableObject {
}
In SwiftUI, all updates to the user interface must happen on the main thread. By marking our AuthenticationManager
with @MainActor
, we ensure that every function and property inside the class runs on the main thread by default. This makes our code safer and simpler by eliminating the need to manually jump to the main thread when updating UI-related state.
Step 2.2: Define our properties
Firstly, we define our authentication state:
enum AuthenticationState {
case start, success, failure, locked
}
An enum
defines a custom set of possible values — like .start, .success, .failure, .locked
— that you can use as a type for your properties. .start
will be – you guessed it – the starting authentication state – no authentication has been done yet. .success
will be the authentication state when successfully unlocked, .failure
when the authentication failed, and .locked
when too many attempts (in our case 3 as you will see in the next paragraph) failed and the app is locked for the next 5 minutes.
Secondly, we’ll need several @Published
properties to track authentication progress and lockout state:
@Published var authenticationState: AuthenticationState = .start
@Published var remainingAttempts: Int = 3
@Published var isAuthenticating = false
@Published var remainingLockTime: Int? // In seconds, for countdown
When we declare a property with @Published
, we tell SwiftUI: “Watch this value. If it changes, update the UI automatically.”
📡 It’s like putting a listener on a variable — whenever it changes, the view updates instantly.
We also define some internal constants:
private let lockoutDuration: TimeInterval = 5 * 60 // 5 minutes
private let lockoutKey = "lockoutTimestamp"
Step 2.3: Check for existing lockout
When the app starts, we want to check if the user is still locked out from a previous session:
init() {
checkLockoutStatus()
}
This will read from UserDefaults to restore the lockout state, even if the app was closed and reopened:
func checkLockoutStatus() {
guard let lockoutTimestamp = UserDefaults.standard.object(forKey: lockoutKey) as? Date else {
return
}
let timePassed = Date().timeIntervalSince(lockoutTimestamp)
if timePassed < lockoutDuration {
authenticationState = .locked
updateCountdown()
} else {
resetLockout()
}
}
We try to read a stored timestamp from UserDefaults. This is the time when the user was last locked out. If there’s no stored timestamp, we simply return — meaning the user is not locked out and we do nothing. We calculate how much time has passed since the lockout began by comparing the current time (Date()
) to the stored lockout timestamp.
If less than lockoutDuration
(in our case: 5 minutes) have passed, we set the state to .locked
and start the countdown timer (which updates the UI). If more lockoutDuration
have passed, we call resetLockout()
to restore normal authentication:
private func resetLockout() {
UserDefaults.standard.removeObject(forKey: lockoutKey)
remainingAttempts = 3
remainingLockTime = nil
authenticationState = .start
}
This function resets the lockout state after the lockout period has ended, so the user can try authenticating again. UserDefaults.standard.removeObject(forKey: lockoutKey)
deletes the previously stored lockout timestamp from UserDefaults
. It ensures the app no longer considers the user locked out. Without this, the app would keep using the old timestamp and think the user is still blocked.
We give the user three new authentication attempts, just like at the beginning. This resets the “strike count” after a lockout has expired.
remainingLockTime = nil
clears the countdown, so no time is shown on the locked screen anymore. And with authenticationState = .start
we set the app back to its initial state, showing the start screen and allowing the user to try again.
Step 2.4: Perform authentication with concurrency
The authenticate() method uses Swift 6’s async/await style to start biometric authentication:
func authenticate() {
Task {
isAuthenticating = true
let result = await performAuthentication()
isAuthenticating = false
switch result {
case .success:
authenticationState = .success
case .failure:
remainingAttempts -= 1
if remainingAttempts <= 0 {
startLockout()
} else {
authenticationState = .failure
}
}
}
}
This function starts the authentication process using Swift Concurrency. It runs asynchronously inside a Task
, so it doesn’t block the UI. It sets isAuthenticating to true while waiting for Face ID or Touch ID. After the attempt finishes, it updates the app’s state:
- ✅ On success → shows the success screen
- ❌ On failure → decreases the attempt counter
- If no attempts are left → starts a lockout
- Otherwise → shows the failure screen
The actual work happens in a helper method that uses withCheckedContinuation
— a Swift Concurrency feature that lets us work with older APIs (like evaluatePolicy
) in an async world:
private func performAuthentication() async -> Result<Void, Error> {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
return .failure(error ?? NSError(domain: "Auth", code: 0))
}
let reason = "Please authenticate to unlock the app"
return await withCheckedContinuation { continuation in
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, authError in
if success {
continuation.resume(returning: .success(()))
} else {
continuation.resume(returning: .failure(authError ?? NSError(domain: "Auth", code: 1)))
}
}
}
}
This setup allows the app to wait for the result of Face ID or Touch ID without blocking the UI — a core benefit of using Swift Concurrency.
let context = LAContext()
creates an authentication context to interact with Face ID, Touch ID, or device passcode. These lines
var error: NSError?
guard context.canEvaluatePolicy(...) else { ... }
check if the device supports biometric or passcode authentication. If not, we return a failure immediately with an error.
Apple’s LocalAuthentication
API still uses completion handlers — the older callback-style approach. But in Swift Concurrency, we want to work with async/await. To bridge this gap, we use:
await withCheckedContinuation { continuation in
context.evaluatePolicy(...) { success, error in
continuation.resume(returning: ...)
}
}
withCheckedContinuation
lets us wrap old-style code inside an async world, so we can use cleaner Swift syntax and structure. It gives us all the benefits of Swift Concurrency while still working with legacy APIs.
The line
continuation.resume(returning: .failure(authError ?? NSError(domain: "Auth", code: 1)))
continuation.resumes(returning:)
resumes the suspended async function — it tells Swift: “We’re done waiting now — here’s the result of the authentication process.” We’re returning a Result type, specifically a .failure(…) because the authentication failed.
.failure(authError ?? NSError(domain: "Auth", code: 1))
means the following:
- .failure(…) indicates that the operation didn’t succeed.
- Inside the failure, we provide an Error object — ideally the one from Apple (authError).
- authError is the optional Error? value returned by the biometric system.
- If Face ID or Touch ID fails, Apple might give us a reason (like cancellation or system error).
- But sometimes, authError is nil — so we provide a fallback error to make sure the app always has something meaningful to work with:
NSError(domain: "Auth", code: 1)
This creates a custom fallback error with:
- A domain “Auth” (you define this — it helps identify where the error came from)
- An error code 1 (you choose this too)
This gives us a consistent error structure even if the system doesn’t tell us why authentication failed. This ensures your logic always has an error to handle, which makes your app more robust and prevents unexpected crashes or empty error states.
Step 2.5: Handle lockout after failed attempts
If the user fails authentication three times, we start a lockout:
private func startLockout() {
let lockoutTimestamp = Date()
UserDefaults.standard.set(lockoutTimestamp, forKey: lockoutKey)
authenticationState = .locked
updateCountdown()
}
This function activates the lockout after too many failed authentication attempts.
- It stores the current time as the lockout start using
UserDefaults
, so the lockout persists even if the app is closed. - It sets the app’s state to
.locked
, triggering the lockout screen. - It starts the countdown timer to show how much time remains before retrying.
This ensures users can’t bypass the lockout by restarting the app.
Step 2.6: Show countdown with Swift Concurrency
Instead of using a legacy Timer, we use Task.sleep(for:)
from Swift Concurrency to show a smooth countdown:
func updateCountdown() {
Task {
while let lockoutTimestamp = UserDefaults.standard.object(forKey: lockoutKey) as? Date {
let timeRemaining = Int(lockoutDuration - Date().timeIntervalSince(lockoutTimestamp))
if timeRemaining <= 0 {
resetLockout()
break
} else {
remainingLockTime = timeRemaining
}
try? await Task.sleep(for: .seconds(1))
}
}
}
This updates the UI every second without relying on GCD or timers. It starts an asynchronous task that runs while a lockout timestamp exists. It reads the stored lockout start time from UserDefaults
and calculates how many seconds are left in the lockout period. If the countdown reaches 0, the lockout ends and the state resets. Otherwise, it updates the remainingLockTime, which updates the UI. try? await Task.sleep(for: .seconds(1))
– pauses for 1 second before repeating, creating a live ticking countdown.
Great work! You’ve now created a reusable, testable AuthenticationManager
that uses Swift Concurrency to handle real-world biometric authentication and lockout logic — all without blocking the UI.
In the next step, we’ll connect this manager to our user interface using SwiftUI. Ready to build the first screen? Let’s go!
Step 3: Build the User Interface with SwiftUI
Now that we’ve built our authentication logic in AuthenticationManager, it’s time to connect it to the user interface using SwiftUI.
Our app has four main views:
AuthenticationView
: the starting screen where the user taps to authenticateUnlockedView
: shown after successful authenticationPasswordView
: shown when authentication fails but retries are still allowedLockedView
: shown when too many failed attempts have triggered a lockoutContentView
: The view that brings all together.
We’ll show these screens based on the current state stored in authManager.
Step 3.1: Start with ContentView
This is the central view that decides what to show based on the current authenticationState
. First, inside the ‘ContentView’, we use @StateObject
to create a single instance of AuthenticationManager
and it creates it only once, when the view first appears. It also keeps the object alive, even if the view body is re-evaluated multiple times.
Based on the current authenticationState, we switch between views we will create in a moment:
struct ContentView: View {
@StateObject private var authManager = AuthenticationManager()
var body: some View {
VStack {
Spacer()
switch authManager.authenticationState {
case .start:
// tbd
case .success:
// tbd
case .failure:
// tbd
case .locked:
// tbd
}
Spacer()
Spacer()
}
}
}
Step 3.2: Create the AuthenticationView
This is the screen the user sees when they launch the app. It prompts them to tap the FaceID button and authenticate.
Create a new SwiftUI file. Go to File - New - File from Template...
or simply press cmd + N
, choose iOS Template
and here SwiftUI View
, click Next
, choose AuthenticationView
as the name of the new view, ensure that Unlockly
is selected under Group
, check the tick box for Targets: Unlockly
, click Create
.
In our AuthenticationView
we first define an instance of the AuthenticationManager
called authManager
. We use an @ObservedObject
that ensures your authentication logic is shared across all views, and all state changes (success, failure, lockout) are automatically reflected in the UI.
struct AuthenticationView: View {
@ObservedObject var authManager: AuthenticationManager
var body: some View {
VStack {
Button {
authManager.authenticate()
} label: {
VStack {
Image(systemName: "faceid")
.resizable()
.frame(width: 80, height: 80)
Text("Tap")
}
}
}
.padding()
}
}
The user sees a Face ID icon and a “Tap” button. When the button is tapped, it calls authManager.authenticate()
, which kicks off the Face ID / Touch ID authentication. We pass in the shared authManager
from ContentView
.
Step 3.3: Create the UnlockedView
Create a new SwiftUI file called UnlockedView
.
If authentication succeeds, we show a welcoming screen.
struct UnlockedView: View {
var body: some View {
VStack {
Spacer()
Text("Welcome!")
.font(.title.bold())
Text("You successfully unlocked the app.")
.padding()
Image(systemName: "checkmark.seal")
.resizable()
.frame(width: 100, height: 100)
.foregroundColor(.green)
.padding()
Spacer()
Spacer()
}
}
}
You can adjust to your own taste! This view is just a confirmation that authentication succeeded.
Step 3.4: Create the PasswordView
Create a new SwiftUI file called PasswordView
.
This view appears when authentication fails but the user still has attempts left.
struct PasswordView: View {
let remainingAttempts: Int
let retryAction: () -> Void
@ObservedObject var authManager: AuthenticationManager
var body: some View {
VStack(spacing: 20) {
Spacer()
Text("Authentication Failed")
.font(.title.bold())
.foregroundColor(.red)
Image(systemName: "xmark.seal")
.resizable()
.frame(width: 100, height: 100)
.foregroundColor(.red)
.padding()
Text("Attempts remaining: \(remainingAttempts)")
.foregroundColor(.secondary)
if authManager.isAuthenticating {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
} else {
Button(action: retryAction) {
Text("Try Again")
.padding()
.frame(maxWidth: .infinity)
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(12)
}
.padding(.horizontal)
}
Spacer()
Spacer()
}
.padding()
}
}
It gets the number of remaining attempts from ContentView
, where the value authManager.remainingAttempts
is passed in as a variable when the view is created.
This view shows a failure message and the number of remaining attempts. If the user taps Try Again, we retry authentication. While authentication is in progress (isAuthenticating = true
), we show a spinner instead of the button.
Step 3.5: Create the LockedView
Create a new SwiftUI file called LockedView
.
This view appears after three failed attempts. It shows a countdown and prevents further interaction.
struct LockedView: View {
@ObservedObject var authManager: AuthenticationManager
var body: some View {
VStack(spacing: 30) {
Spacer()
Image(systemName: "lock.shield")
.resizable()
.scaledToFit()
.frame(width: 100, height: 100)
.foregroundColor(.red)
Text("Too Many Attempts")
.font(.title)
.fontWeight(.bold)
.foregroundColor(.primary)
if let remaining = authManager.remainingLockTime {
Text("Try again in \(formatTime(seconds: remaining))")
.font(.headline)
.foregroundColor(.secondary)
}
Text("Access is temporarily locked for security reasons.")
.multilineTextAlignment(.center)
.font(.footnote)
.foregroundColor(.gray)
Spacer()
Spacer()
}
.padding()
}
func formatTime(seconds: Int) -> String {
let minutes = seconds / 60
let secs = seconds % 60
return String(format: "%02d:%02d", minutes, secs)
}
}
This view does three things:
- Shows a lock icon and message.
- Displays a countdown timer (
remainingLockTime
), updated by theAuthenticationManager
. - Prevents further attempts until the time is up.
Step 3.6 Update our ContentView
Now we an update our ContentView
according to the authentication state. Replace the previous tbd comments with the respective view:
switch authManager.authenticationState {
case .start:
AuthenticationView(authManager: authManager)
case .success:
UnlockedView()
case .failure:
PasswordView(
remainingAttempts: authManager.remainingAttempts,
retryAction: authManager.authenticate,
authManager: authManager
)
case .locked:
LockedView(authManager: authManager)
}
If you want to trigger the authentication immediately when the app starts without the user tapping the button, you could add an .onAppear()
modifier to the VStack
:
.onAppear {
if authManager.authenticationState == .start {
authManager.authenticate()
}
}
This way, the app automatically triggers biometric authentication without the user needing to tap the Face ID button again after launch or after unlocking.
Step 4: Run your app
You see already a preview of your app on the right-hand side in the canvas. To run your app, it’s best to use a physical device – and press the Play button or simply use Cmd + R
.
Congratulations!
You’ve successfully build an authentication app! 🎉
What you have learned
In this code-along, you’ve learned how to:
- use Swift Concurrency (
async/await
,Task
) to perform secure background operations without blocking the UI, - integrate Face ID or Touch ID using Apple’s
LocalAuthentication
framework, - build a reusable authentication manager with lockout protection after multiple failed attempts,
- display different views based on authentication state using SwiftUI’s declarative approach,
- track lockout countdown using modern concurrency instead of legacy timers, and
- pass shared state between views using
@StateObject
and@ObservedObject
.
This small app is a great foundation for real-world authentication workflows — and a solid step toward mastering Swift Concurrency.
You made it this far, very well done! 🎉
That’s a wrap!
Keep learning, keep building, and let your curiosity guide you. Happy coding! ✨
You learn the most from things you enjoy doing so much that you don’t even notice time passing. — Albert Einstein
Download the full project on GitHub: https://github.com/swiftandcurious/Unlockly.git