🎯 Learning Path:
Welcome to the REL-ID Server Biometric Step-Up Authentication codelab! This tutorial enhances your notification application with high-security biometric verification using liveness detection for sensitive notification actions.
In this codelab, you'll enhance your existing notification application with:
By completing this codelab, you'll master:
deinit cleanup pattern for reliable handler registrationBefore starting this codelab, ensure you have:
The code to get started can be found in a GitHub repository.
You can clone the repository using the following command:
git clone https://github.com/uniken-public/relid-codelab-ios.git
Navigate to the relid-IDV-post-login-step-up-auth-notification folder in the repository you cloned earlier
This codelab adds server biometric step-up authentication to your notification application with three core enhancements:
onGetIDVSelfieProcessStartConfirmation handler for workflow 9 with handler chaining patternnavigationController.popToViewController() for smooth transitionsBefore implementing server biometric authentication, let's understand the key differences from password step-up and the unique event flow.
The password step-up codelab (prerequisite) uses Challenge Mode 3 for re-authentication. Server biometric authentication uses a different mechanism:
Aspect | Password Step-Up (Prerequisite) | Server Biometric Step-Up (This Codelab) |
Backend Configuration |
|
|
SDK Event Triggered |
|
|
Event Parameter |
|
|
UI Pattern | Modal overlay (StepUpPasswordViewController) | Full screen navigation (IDVSelfieProcessStartConfirmationViewController) |
Authentication Method | Password input verification | Selfie capture with liveness detection |
Backend Verification | Password hash verification | Biometric template matching |
Security Level | Standard re-authentication | High-security with anti-spoofing |
The server biometric step-up authentication follows this event-driven pattern:
User Taps Notification Action → updateNotification(uuid, action) API Called →
Backend Determines authLevel = 4 (Server Biometric Required) →
SDK Triggers: onGetIDVSelfieProcessStartConfirmation Event (idvWorkflow: 9) →
[Screen-Level Handler Executes FIRST]:
handleSelfieProcessStartForNotification() →
Checks if idvWorkflow.rawValue == 9 →
Calls dismissActionModal() → Action Modal Closes →
[Global Handler Executes SECOND]:
originalGetSelfieHandler() (from RDNAIDVDelegateManager) →
Navigates to IDVSelfieProcessStartConfirmationViewController →
User Reviews Guidelines → Taps "Start Selfie Capture" →
SDK Captures Selfie → Liveness Detection (Aware FaceCapture) →
SDK Verifies Biometric Template on Server →
SDK Triggers: onUpdateNotification Event →
[Screen-Level Handler Executes]:
handleUpdateNotificationReceived() →
Detects Selfie Screen via Navigation Stack →
Closes Selfie Screen with popToViewController() →
Delays 300ms for Screen Transition →
Displays Success/Error Alert →
Navigates to Dashboard
Important: The authentication method is determined by the authLevel configured in the backend for each notification action:
AuthLevel | Authentication Method | SDK Behavior | Use Case |
1 | Password | SDK triggers | Standard security actions (prerequisite codelab) |
3 | LDA (Local Device Auth) | SDK handles biometric internally | Standard biometric actions |
4 | Server Biometric | SDK triggers | High-security actions requiring liveness |
IDV Workflow 9 is specifically designed for notification step-up authentication:
IDV Workflow | Purpose | When Used | Screen Navigation |
9 | Notification Step-Up Authentication | Sensitive notification actions with authLevel=4 | Must close action modal first |
10 | Biometric Opt-In | User enrolls in biometric authentication | Standalone flow |
6 | Post-Login KYC | Know Your Customer verification | Standalone flow |
8 | Post-Login Selfie | Biometric authentication after login | Standalone flow |
15 | Login Selfie | Biometric verification during login | Pre-login flow |
The REL-ID SDK uses these components for server biometric step-up:
Component | Description | Developer Action |
Aware FaceCapture Library | Embedded in RELIDIDV framework for liveness detection | Pre-configured, no action needed |
onGetIDVSelfieProcessStartConfirmation | SDK event when selfie capture is required (idvWorkflow: 9) | Register handler in GetNotificationsViewController |
IDVSelfieProcessStartConfirmationViewController | Selfie capture initiation screen with guidelines | Already implemented in project |
onUpdateNotification | SDK event with authentication result | Handle result, close selfie screen, show alert |
Now let's implement the screen-level event handler that intercepts onGetIDVSelfieProcessStartConfirmation events for workflow 9.
For server biometric step-up, we need two actions when the selfie event is triggered:
This requires a handler chaining pattern where our screen handler executes first, then calls the global handler:
// Screen handler executes first
handleSelfieProcessStartForNotification(idvWorkflow: idvWorkflow) // Close modal
// Then call original handler
originalGetSelfieProcessStartHandler?(idvWorkflow) // Navigate to selfie screen
Add the handler that intercepts workflow 9 events to close the action modal:
// GetNotificationsViewController.swift
/// Handle selfie process start confirmation for notification step-up (idvWorkflow: 9)
///
/// This event is triggered when SDK starts server biometric authentication
/// for a notification action with authLevel = 4.
///
/// Critical: Close action modal BEFORE SDK navigates to selfie screen
private func handleSelfieProcessStartForNotification(idvWorkflow: RDNAIDVWorkflow) {
print("GetNotificationsViewController - Selfie process start confirmation received:")
print(" idvWorkflow: \(idvWorkflow.rawValue)")
// Check if this is notification step-up flow (idvWorkFlow: 9)
if idvWorkflow.rawValue == 9 { // IDV_POSTLOGIN_STEPUP_NOTIFICATION
print("GetNotificationsViewController - Server biometric step-up for notification detected")
// CRITICAL: Close action modal to allow selfie screen to appear on top
dismissActionModal()
print("GetNotificationsViewController - Action modal closed, selfie screen will appear")
// SDK will automatically navigate to IDVSelfieProcessStartConfirmationViewController
// Global handler in SDKIDVEventProvider will handle navigation
} else {
print("GetNotificationsViewController - Different IDV workflow, ignoring in notification screen: \(idvWorkflow.rawValue)")
}
}
Register the handler in setupEventHandlers() to preserve the global handler:
// GetNotificationsViewController.swift
private func setupEventHandlers() {
print("GetNotificationsViewController - setupEventHandlers called")
// Setup SDK event handlers
RDNADelegateManager.shared.onGetNotifications = { [weak self] status in
print("GetNotificationsViewController - onGetNotifications handler FIRED")
self?.handleNotificationsReceived(status: status)
}
RDNADelegateManager.shared.onUpdateNotification = { [weak self] status in
print("GetNotificationsViewController - onUpdateNotification handler FIRED")
self?.handleUpdateNotificationReceived(status: status)
}
print("GetNotificationsViewController - onUpdateNotification handler registered")
// STEP-UP AUTH: Preserve original getPassword handler and chain for challengeMode 3
originalGetPasswordHandler = RDNADelegateManager.shared.onGetPassword
RDNADelegateManager.shared.onGetPassword = { [weak self] userID, challengeMode, attemptsLeft, response, error in
self?.handleGetPasswordStepUp(userID: userID, challengeMode: challengeMode, attemptsLeft: attemptsLeft, response: response, error: error)
}
// IDV SELFIE HANDLER: Register IDV selfie handler for server biometric step-up (idvWorkflow: 9)
print("GetNotificationsViewController - Registering IDV selfie handler")
originalGetSelfieProcessStartHandler = RDNAIDVDelegateManager.shared.onGetIDVSelfieProcessStartConfirmation
print("GetNotificationsViewController - Original selfie handler exists: \(originalGetSelfieProcessStartHandler != nil)")
RDNAIDVDelegateManager.shared.onGetIDVSelfieProcessStartConfirmation = { [weak self] idvWorkflow in
// First handle notification-specific logic (close modal)
self?.handleSelfieProcessStartForNotification(idvWorkflow: idvWorkflow)
// Then call original handler for navigation
self?.originalGetSelfieProcessStartHandler?(idvWorkflow)
}
print("GetNotificationsViewController - IDV selfie handler registered")
}
Add cleanup in deinit to match the iOS lifecycle pattern:
// GetNotificationsViewController.swift
// Properties
private var originalGetSelfieProcessStartHandler: ((_ idvWorkflow: RDNAIDVWorkflow) -> Void)?
deinit {
// Cleanup event handlers (matches UIKit lifecycle)
// Note: Cleanup is in deinit (on view controller deallocation), not viewWillDisappear
// This matches the pattern where handlers should persist when screen is just hidden
print("GetNotificationsViewController - deinit, cleaning up event handlers")
cleanupEventHandlers()
}
private func cleanupEventHandlers() {
print("GetNotificationsViewController - cleanupEventHandlers called")
// Cleanup SDK event handlers
print("GetNotificationsViewController - Removing onGetNotifications handler")
RDNADelegateManager.shared.onGetNotifications = nil
print("GetNotificationsViewController - Removing onUpdateNotification handler")
RDNADelegateManager.shared.onUpdateNotification = nil
// STEP-UP AUTH: Restore original getPassword handler
print("GetNotificationsViewController - Restoring original getPassword handler")
RDNADelegateManager.shared.onGetPassword = originalGetPasswordHandler
originalGetPasswordHandler = nil
// IDV SELFIE HANDLER: Restore original selfie handler
print("GetNotificationsViewController - Restoring original selfie handler")
RDNAIDVDelegateManager.shared.onGetIDVSelfieProcessStartConfirmation = originalGetSelfieProcessStartHandler
originalGetSelfieProcessStartHandler = nil
print("GetNotificationsViewController - All handlers cleaned up")
}
Now let's implement the enhanced onUpdateNotification handler that closes the selfie screen before showing alerts.
After server biometric verification completes, the onUpdateNotification event is triggered. At this point, the selfie screen (IDVSelfieProcessStartConfirmationViewController) may still be visible on top of the GetNotificationsViewController.
We need to:
The navigation stack pattern allows us to navigate across hierarchies:
UINavigationController
├─ GetNotificationsViewController (self)
└─ IDVSelfieProcessStartConfirmationViewController (pushed on top)
To close the selfie screen from GetNotificationsViewController, we check the navigation stack:
if let navController = navigationController {
let selfIndex = navController.viewControllers.firstIndex(of: self)
if selfIndex < navController.viewControllers.count - 1 {
// There are screens above us
navController.popToViewController(self, animated: false)
}
}
First, extract the notification update result processing into a separate function:
// GetNotificationsViewController.swift
/// Handle notification update result
/// Extracted logic to process notification update response
/// This allows reuse whether we delay (biometric) or process immediately (password)
private func handleNotificationUpdateResult(status: RDNAStatusUpdateNotification) {
print("GetNotificationsViewController - Processing notification update result")
// STEP-UP AUTH: Check for LDA cancelled (error code 131)
let error = status.error
if error.longErrorCode == 131 {
print("GetNotificationsViewController - LDA cancelled, Password not enrolled")
dismissStepUpModal()
showAlert(
title: "Authentication Cancelled",
message: "Local device authentication was cancelled. Please try again."
) {
// Keep action modal open to allow user to retry
}
return
}
// Check for other SDK errors
if error.longErrorCode != 0 {
let errorMessage = error.errorString
print("GetNotificationsViewController - Update error: \(errorMessage)")
// Dismiss modals FIRST, then show alert
dismissStepUpModal()
dismissActionModal()
// Show alert after a short delay (let modals dismiss first)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
self.showAlert(title: "Update Failed", message: errorMessage) {
// Refresh notifications after error
self.loadNotifications()
}
}
return
}
// Check response status
let responseStatus = status.status
let statusCode = responseStatus.statusCode.rawValue
if statusCode == 0 || statusCode == 100 { // RESP_STATUS_SUCCESS
print("GetNotificationsViewController - Update successful")
// Success - dismiss modals and show success alert
dismissStepUpModal()
dismissActionModal()
// Refresh notifications
loadNotifications()
// Show success alert after a short delay (let modals dismiss first)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
self.showAlert(
title: "Success",
message: responseStatus.message ?? "Action completed successfully"
)
}
} else if statusCode == 110 || statusCode == 153 {
// STEP-UP AUTH: Critical errors (110=password expired, 153=attempts exhausted)
print("GetNotificationsViewController - Critical error: \(statusCode)")
let statusMessage = responseStatus.message
// Dismiss modals FIRST
dismissStepUpModal()
dismissActionModal()
// Show alert BEFORE SDK triggers logout
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
self.showAlert(
title: "Authentication Failed",
message: statusMessage ?? "Authentication failed"
) {
print("GetNotificationsViewController - Waiting for SDK to trigger logout flow")
// SDK will automatically trigger onUserLoggedOff event
}
}
} else {
let statusMessage = responseStatus.message ?? "Update failed"
print("GetNotificationsViewController - Update status error: \(statusMessage)")
// Dismiss modals FIRST, then show alert
dismissStepUpModal()
dismissActionModal()
// Show alert after a short delay (let modals dismiss first)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
self.showAlert(title: "Update Failed", message: statusMessage) {
// Refresh notifications after alert
self.loadNotifications()
}
}
}
}
Enhance the handleUpdateNotificationReceived handler to detect and close the selfie screen:
// GetNotificationsViewController.swift
/// Handle update notification response from onUpdateNotification event
///
/// Updated: Now checks if selfie screen is active and closes it before showing alerts
/// This prevents alerts from appearing behind the selfie screen during server biometric step-up
private func handleUpdateNotificationReceived(status: RDNAStatusUpdateNotification) {
print("GetNotificationsViewController - ========================================")
print("GetNotificationsViewController - handleUpdateNotificationReceived CALLED")
print("GetNotificationsViewController - Error code: \(status.error.longErrorCode)")
print("GetNotificationsViewController - Status code: \(status.status.statusCode.rawValue)")
print("GetNotificationsViewController - ========================================")
actionLoading = false
actionModal?.isActionLoading = false // Reset modal loading state
stepUpSubmitting = false
/**
* Check if selfie screen is active and close it before processing
* Pattern: Check if there are view controllers on the navigation stack above us
* If yes, pop back to self (closes selfie screen and any other screens)
*/
func closeSelfieScreenIfPresent() -> Bool {
print("GetNotificationsViewController - [closeSelfieScreenIfPresent] Starting check...")
// Get navigation controller
guard let navController = navigationController else {
print("GetNotificationsViewController - [closeSelfieScreenIfPresent] ❌ Navigation controller is NIL")
return false
}
print("GetNotificationsViewController - [closeSelfieScreenIfPresent] Navigation controller exists")
print("GetNotificationsViewController - [closeSelfieScreenIfPresent] Total VCs in stack: \(navController.viewControllers.count)")
// Print all VCs in the stack
for (index, vc) in navController.viewControllers.enumerated() {
print("GetNotificationsViewController - [closeSelfieScreenIfPresent] [\(index)] \(type(of: vc))")
}
// Check if there are other view controllers on the navigation stack above us
if let selfIndex = navController.viewControllers.firstIndex(of: self) {
print("GetNotificationsViewController - [closeSelfieScreenIfPresent] Found self at index: \(selfIndex)")
if selfIndex < navController.viewControllers.count - 1 {
// There are screens on top of us (like IDVSelfieProcessStartConfirmationViewController)
let vcsAbove = navController.viewControllers.count - 1 - selfIndex
print("GetNotificationsViewController - [closeSelfieScreenIfPresent] ✅ Found \(vcsAbove) view controller(s) above us")
// List what's above us
for i in (selfIndex + 1)..<navController.viewControllers.count {
print("GetNotificationsViewController - [closeSelfieScreenIfPresent] Above us: \(type(of: navController.viewControllers[i]))")
}
print("GetNotificationsViewController - [closeSelfieScreenIfPresent] Calling popToViewController(self, animated: false)")
navController.popToViewController(self, animated: false)
print("GetNotificationsViewController - [closeSelfieScreenIfPresent] ✅ popToViewController completed")
return true
} else {
print("GetNotificationsViewController - [closeSelfieScreenIfPresent] ❌ No view controllers above us (we're at top)")
print("GetNotificationsViewController - [closeSelfieScreenIfPresent] selfIndex: \(selfIndex), count: \(navController.viewControllers.count)")
}
} else {
print("GetNotificationsViewController - [closeSelfieScreenIfPresent] ❌ Could not find self in navigation stack!")
}
return false
}
// Check and close selfie screen if present
print("GetNotificationsViewController - About to call closeSelfieScreenIfPresent()")
let selfieScreenWasClosed = closeSelfieScreenIfPresent()
print("GetNotificationsViewController - closeSelfieScreenIfPresent() returned: \(selfieScreenWasClosed)")
if selfieScreenWasClosed {
// Delay processing to allow screen transition to complete
print("GetNotificationsViewController - ✅ Selfie screen was closed, delaying result processing for screen transition (300ms)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
print("GetNotificationsViewController - Processing notification update result after screen close (delayed)")
self.handleNotificationUpdateResult(status: status)
}
print("GetNotificationsViewController - Exiting early, asyncAfter will handle processing")
return // Exit early, let asyncAfter handle the rest
}
// Selfie screen not present (password/LDA step-up), process immediately
print("GetNotificationsViewController - ❌ Selfie screen was NOT closed (not present)")
print("GetNotificationsViewController - Processing notification update result immediately")
handleNotificationUpdateResult(status: status)
}
Now let's test the complete server biometric step-up implementation.
Before testing, ensure your REL-ID server is configured for server biometric step-up:
Test the complete server biometric step-up flow:
getNotifications() succeededConsole logs should show:
- "GetNotificationsViewController - Calling updateNotification API"
- "GetNotificationsViewController - Selfie process start confirmation received"
- "GetNotificationsViewController - Server biometric step-up for notification detected"
- "GetNotificationsViewController - Action modal closed, selfie screen will appear"
- "RDNAIDVDelegateManager - Selfie process start confirmation requested"
Console logs should show:
- "GetNotificationsViewController - Received update notification event"
- "GetNotificationsViewController - Checking if selfie screen needs to be closed"
- "GetNotificationsViewController - Found X view controller(s) above us"
- "GetNotificationsViewController - Calling popToViewController(self, animated: false)"
- "GetNotificationsViewController - Selfie screen was closed, delaying result processing (300ms)"
- "GetNotificationsViewController - Processing notification update result after screen close"
Test user cancellation during selfie capture:
Test server biometric verification failure:
Use this checklist to verify your implementation:
popToViewController() before alertLet's understand the architecture decisions and troubleshoot common issues.
The implementation uses deinit for handler cleanup instead of viewWillDisappear. This is a deliberate architectural choice:
deinit (runs on deallocation):
viewWillDisappear (would run too early):
Why deinit is better:
// Scenario with deinit (correct):
1. Screen appears → setupEventHandlers() registers handler
2. User taps action → updateNotification() called
3. Selfie screen appears → viewWillDisappear() runs (but handlers remain)
4. Selfie completes → onUpdateNotification handler still active ✅
5. Screen deallocated → deinit runs, handlers cleaned up
// Scenario with viewWillDisappear (incorrect):
1. Screen appears → setupEventHandlers() registers handler
2. User taps action → updateNotification() called
3. Selfie screen appears → viewWillDisappear() runs, handlers removed ❌
4. Selfie completes → onUpdateNotification handler is nil, event missed ❌
Current approach (better): Check navigation controller stack directly
Advantages of navigation-based check:
// ✅ Better: Navigation stack as source of truth
if let selfIndex = navController.viewControllers.firstIndex(of: self) {
if selfIndex < navController.viewControllers.count - 1 {
// Screens are definitely present above us
}
}
// ❌ Worse: Manual flag tracking
if isSelfieScreenActive {
// Flag might be out of sync with actual screen state
}
Issue 1: Action Modal Not Hiding
Symptoms: Selfie screen appears behind action modal
Solution: Verify handler registration
// Check console logs:
// - "GetNotificationsViewController - Registering IDV selfie handler"
// - "GetNotificationsViewController - IDV selfie handler registered"
// - "GetNotificationsViewController - Original selfie handler exists: true"
// Ensure handler is registered in setupEventHandlers()
RDNAIDVDelegateManager.shared.onGetIDVSelfieProcessStartConfirmation = { [weak self] idvWorkflow in
self?.handleSelfieProcessStartForNotification(idvWorkflow: idvWorkflow)
self?.originalGetSelfieProcessStartHandler?(idvWorkflow)
}
Issue 2: Alert Appears Behind Selfie Screen
Symptoms: Success alert not visible, selfie screen still open
Solution: Ensure navigation stack check and delay are implemented
let selfieScreenWasClosed = closeSelfieScreenIfPresent()
if selfieScreenWasClosed {
// CRITICAL: 300ms delay for screen transition
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.handleNotificationUpdateResult(status: status)
}
return
}
Issue 3: Handler Not Triggering for Workflow 9
Symptoms: Console logs show selfie event but handler not executing
Solution: Check property name (common mistake)
// ✅ Correct property name
RDNAIDVDelegateManager.shared.onGetIDVSelfieProcessStartConfirmation = handler
// ❌ Wrong property name (easy to confuse)
RDNAIDVDelegateManager.shared.onIDVSelfieProcessStartConfirmation = handler
Issue 4: Other IDV Workflows Broken
Symptoms: Biometric opt-in, KYC flows not working after implementation
Solution: Ensure workflow 9 check doesn't block other workflows
if idvWorkflow.rawValue == 9 {
dismissActionModal()
// Only affect workflow 9
} else {
print("Different IDV workflow, ignoring: \(idvWorkflow.rawValue)")
// Don't interfere with workflows 10, 6, 8, 15, etc.
}
Let's review important security considerations for server biometric authentication.
Never expose biometric templates:
// ❌ Wrong - Logging biometric data
print("Biometric template: \(selfieData.template)")
// ✅ Correct - Only log workflow and status
print("Server biometric verification:", [
"idvWorkflow": idvWorkflow.rawValue,
"success": statusCode == 100
])
Trust SDK's liveness detection:
The Aware FaceCapture library embedded in RELIDIDV framework provides:
Never bypass authLevel requirements:
// ❌ Wrong - Trying to bypass server biometric
if requiresServerBiometric {
// Don't call updateNotification again to skip auth
}
// ✅ Correct - Always respect backend authLevel
let error = RDNAService.shared.updateNotification(uuid, response: action)
// Let SDK handle authLevel=4 requirement via events
Congratulations! You've successfully implemented server biometric step-up authentication for notification actions with REL-ID SDK.
In this codelab, you've learned how to:
✅ Understand AuthLevel Configuration: Learned how authLevel=4 triggers server biometric (vs challengeMode 3 for password)
✅ Implement IDV Workflow 9: Handled notification-specific selfie capture with liveness detection
✅ Use deinit Cleanup Pattern: Applied proper iOS lifecycle cleanup for reliable event handling
✅ Chain Event Handlers: Preserved global navigation while adding screen-specific logic (close modal)
✅ Manage Modal Lifecycle: Closed action modal before selfie screen to prevent UI blocking
✅ Use Navigation Stack Pattern: Navigated across view controller hierarchies to close screens smoothly
✅ Implement Screen Transition Timing: Used 300ms delays for smooth UI updates before alerts
✅ Integrate Liveness Detection: Leveraged Aware FaceCapture library for anti-spoofing
AuthLevel vs Challenge Mode:
authLevel = 1, challengeMode = 3, modal overlayauthLevel = 4, idvWorkflow = 9, full screen selfie capturedeinit Cleanup Pattern:
Navigation Stack Pattern:
navigationController.viewControllers.firstIndex(of: self)popToViewController(self, animated: false)Handler Chaining:
Thank you for completing this codelab! You now have the knowledge to implement secure, production-ready server biometric step-up authentication for notification actions in your iOS applications.
Happy Coding!