Skip to main content

Wallet Setup (iOS)

A wallet is the interface your users interact with to authenticate and complete transactions - typically hosted on a dedicated domain like wallet.example.com. It handles passkey authentication and transaction approval.

The Wallet SDK supports two key user scenarios involving MFA and passkeys:

  1. Creating a passkey as part of your existing banking sign-up/sign-in flow
  2. Using that passkey to authenticate payment actions

Requirements

  • iOS 16+
  • XCode 16+
  • Swift 6+

Prerequisites

  • Create an application to obtain a base URL. The SDK uses this base URL to interact with the LoginID authentication service.
  • Create an API key with at least the external:verify scope. You’ll need this to request authorization tokens from your backend.

Configure apple-app-site-association File

In order for passkeys to work with your iOS application, you need to link your application with a website. You do this by creating an apple-app-site-association and hosting it on your domain website. More info here.

You Need an Apple Developer Account

An Apple Developer Account is a requirement as the Associated Domains capability is not available for free. Currently, an Apple Developer Account can be obtained from https://developer.apple.com/support/enrollment.

Obtain Team ID and Bundler ID

  • The team ID can be obtained on your developer account console at https://developer.apple.com/account.
  • The bundler ID can be obtained on your Signing & Capabilities section of your application.

Host apple-app-site-association JSON File on Your Website Directory

To host an apple-app-site-association file, you need to serve it as a static JSON file on your website. Note that the file must be named exactly apple-app-site-association without the .json extension and your server must be configured to serve it as JSON. The file should be located at <WEBSITE_DOMAIN>/.well-known/apple-app-site-association in the root directory of your website.

Here is an example of the minimum required fields in the file:

{
"webcredentials": {
"apps": ["<TEAM_ID>.<BUNDLER_ID>"]
}
}

Use the following example as a template and replace TEAM_ID and BUNDLER_ID with your values.

More information here.

Create an Associated Domains Capability on Your iOS App

To enable this capability

  1. You must have an Apple Developer Account
  2. Go to the Signing & Capabilities section of your application
  3. Add a capability by clicking the + Capability button
  4. Choose Associated Domains
  5. Enter the domain of your hosted website. Make sure to prefix it with webcredentials. Here's an example of what it should look like:
webcredentials:example.com

Setup SDK

Using the Swift Package Manager

  1. In Xcode, open your project.
  2. Go to File → Add Package Dependencies….
  3. In the search bar, paste:
https://github.com/loginid1/loginid-ios
  1. Select the target(s) where you want to add the SDK and click Add Package.

Import the class within your view models:

import LoginIDWalletAuth

@main
struct MyApp: App {
private let lid: LoginID

init() {
let baseUrl = "<LOGINID_BASE_URL>"
LoginID(baseUrl: baseUrl)

// Other setup code...
}
}
info

You can view the source code here.

Wallet Payment Page Overview

This overview outlines how the Wallet SDK determines the next user action once the checkout flow begins. Using an identifier called trustId, the SDK selects the correct path and guides the user — either confirming the payment with a passkey in a single step (serving as both sign-in and checkout approval), or presenting a dedicated sign-in step, where the user can authenticate with a passkey or a fallback method such as bank login into their wallet.

tip

The overview diagram is numbered to match key steps throughout the rest of this section. You can follow along each action (e.g., [2], [3], [4a], etc.) in the flowchart as you read through the related code and explanations that follow.

[1] Initiate the Flow With beginFlow - SDK Determines the Next Required Action

This is the starting point. The user arrives at the view where the wallet payment process begins.

The code below demonstrates a basic wallet payment page. The Wallet SDK determines the next required action. Based on the response, you decide what to display to the user.

The SDK determines the next step by calling beginFlow with txPayload.

This should be called as soon as the view loads, for example in viewDidLoad.

What is txPayload?

txPayload is a string-based representation of the transaction being authorized. It can be a plain JSON string or an encoded/encrypted format that you define. This payload is included within a challenge and signed by the user's passkey, acting as proof that the user explicitly approved the transaction.

Depending on your implementation, txPayload can be generated on the wallet domain or passed in from the merchant domain (e.g., via WebView or a redirect method).

The result includes nextAction, which guides the UI toward either a passkey transaction or a fallback login.

import SwiftUI
import LoginIDWalletAuth

struct WalletPage: View {
private let txPayload: String
private let lid: LoginID

@State private var action: String? = nil
@State private var error: String = ""

init(txPayload: String) {
self.txPayload = txPayload
self.lid = LoginID(baseUrl: "<LOGINID_BASE_URL>")
}

var body: some View {
VStack {
if let error = error, !error.isEmpty {
Text(error)
.foregroundColor(.red)
}

if action == "transaction" {
TxAuth()
} else if action == "signin" {
WalletSignIn()
}
}
.onAppear {
Task {
await checkFlowHandler()
}
}
}

func checkFlowHandler() async {
do {
// [1] Call beginFlow to start the wallet transaction flow
let result = try await lid.beginFlow(
options: BeginFlowOptions(txPayload: txPayload)
)

// [1] The SDK decides what action to take next
switch result.nextAction {
case .passkeyTx:
action = "transaction"
default:
action = "signin"
}

} catch let error as LoginIDError {
self.error = error.message
} catch {
self.error = error.localizedDescription.isEmpty
? "Failed to start wallet flow."
: error.localizedDescription
}
}
}

The SDK determines the appropriate action using trustId and other context from the wallet.

The following sections provide example implementations for each possible next step and how they can integrate with your existing setup.

Checkout Flows

[2a] Confirming Transaction With Passkey

When an existing user has a registered passkey, beginFlow will return nextAction: "passkey:tx". In this step, the user is not only signing in with their passkey, but also explicitly approving the payment for the selected transaction payload.

info

You can override the txPayload during confirmation by passing a new payload to performAction:

try await lid.performAction(
action: .passkeyTx,
options: PerformActionOptions(txPayload: "<TX_PAYLOAD>")
)

This lets you update transaction details right before user sigin and approval.

import SwiftUI
import LoginIDWalletAuth

struct TxAuth: View {
private let lid: LoginID
@State private var error: String = ""

init() {
self.lid = LoginID(baseUrl: "<LOGINID_BASE_URL>")
}

var body: some View {
VStack {
Button("Pay Your Order") {
Task {
await txConfHandler()
}
}

if !error.isEmpty {
Text(error)
.foregroundColor(.red)
}
}
}

func txConfHandler() async {
do {
// [2a] Confirm transaction using passkey
let result = try await lid.performAction(action: .passkeyTx)

if let payloadSignature = result.payloadSignature {
// Finalize transaction by verifying payloadSignature and returning to merchant
try await finalizeTransaction(payloadSignature: payloadSignature)

// Transaction process is complete, navigate back to merchant here
} else {
// Navigation to error view would go here
error = "Transaction failed."
}
} catch let error as LoginIDError {
self.error = error.message
} catch {
self.error = error.localizedDescription.isEmpty
? "Passkey transaction failed."
: error.localizedDescription
}
}
}

Once confirmed, the user has successfully approved their payment using a passkey.

After confirming the transaction, finalize it using the Finalizing Result to Merchant step.

tip

For details on validating LoginID's payloadSignature, see the transaction confirmation backend section.

[2b] Wallet Passkey Signin

If the result from beginFlow is anything other than nextAction: "passkey:tx", we show a login view that lets the user sign in with either a passkey or a fallback method (in this case, a bank login).

Optionally, you can also trigger passkey autofill. This is useful when the user already has a registered passkey but is signing in from a different device or a browser synced through a password manager.

This is a common first-time experience on a new device.

import SwiftUI
import LoginIDWalletAuth

// [2b] Fallback flow component for login and passkey autofill
struct WalletSignIn: View {
@State private var error: String = ""
@State private var username: String = ""

private let lid: LoginID

init() {
self.lid = LoginID(baseUrl: "<LOGINID_BASE_URL>")
}

var body: some View {
VStack(spacing: 16) {
TextField("Username", text: $username)
.textFieldStyle(RoundedBorderTextFieldStyle())
// Have this as `.username` for passkey autofill to work
.textContentType(.username)

Button("Sign In With Your Bank") {
bankLoginHandler()
}

Button("Sign In With Your Passkey") {
Task {
await passkeyLoginHandler()
}
}

if !error.isEmpty {
Text(error).foregroundColor(.red)
}
}
.padding()
.onAppear {
Task {
await autofill()
}
}
}

private func autofill() async {
do {
// Passkey autofill authentcation
let result = try await lid.performAction(
action: .passkeyAuth,
options: PerformActionOptions(autoFill: true)
)

if let accessToken = result.accessToken {
// [3a] Autofill was successful, finalize payment
try await finalizeTransaction(accessToken: accessToken)
}
} catch let error as LoginIDError {
self.error = error.message
} catch {
self.error = error.localizedDescription
}
}

private func passkeyLoginHandler() async {
do {
// This initiates passkey usernameless authentication.
// After the button is clicked, the user will be prompted to select a passkey profile.
let result = try await lid.performAction(action: .passkeyAuth)

if let accessToken = result.accessToken {
// [3a] Authentication successful, finalize payment
try await finalizeTransaction(accessToken: accessToken)
}
} catch let error as LoginIDError {
self.error = error.message
} catch {
self.error = error.localizedDescription
}
}

private func bankLoginHandler() {
// Redirect to external bank login or another identity method
}
}

If the user completes autofill successfully, they're signed into their wallet and can proceed to payment confirmation. Since a passkey was used, the next session will skip passkey login and go straight to transaction confirmation.

If the user signs in via a fallback login (like bank login), you can guide them to create a passkey for future frictionless access.

After passkey login, complete the flow by following the Finalizing Result to Merchant step.

[3b-1] Fallback Login or New User Onboarding After Bank Redirect

If the user doesn't have a passkey or is on a new device, the wallet may fall back to a traditional login method — such as bank login — as shown in [2b].

After completing the fallback login, the user is redirected back to the wallet app to continue the checkout flow. This section picks up from that point — when the user returns from an external identity provider (e.g., a bank login page).

To keep things simple, the following example just builds on the previous section of bankLoginHandler. In practice, you’d want to use a more secure and production-ready approach.

const bankLoginHandler = (e) => {
guard let url = URL(string: "https://bank.example.com/login?session_id=abc123&redirect_uri=mywallet://post-login") else {
return
}
UIApplication.shared.open(url, options: [:], completionHandler: nil)
};
note

The remaining section covers what happens after a successful bank login — including how to resume the session.

Now that the user has returned to the wallet app, the next step is to verify their login, resume the session, and optionally offer to register a passkey.

But before a passkey can be created, you must perform an external authentication using a LoginID authorization token. This confirms the user's identity and resumes the checkout session.

External Authentication via Bank Login

After the user successfully authenticates with their bank, your backend should validate the login and request an authorization token from LoginID. This token is used to resume the wallet session and optionally prompt the user to create a passkey.

The flow typically looks like this:

  1. User is redirected to your bank's login page.
  2. After login, the user is redirected back to the wallet (e.g., /post-login) with a session identifier.
  3. Your backend verifies the session and makes a POST request to /fido2/v2/mgmt/grant/external-auth to get an authorization token from LoginID. This is where your API key listed on the prerequisites will be needed. See example implementation in code.
  4. The frontend passes this token to the Wallet SDK to complete the authentication process via performAction("external", { payload: token }).

This allows the session to resume and enables passkey registration for future passwordless access.

Sequence Diagram - External Authentication via Bank Login

Once the authorization token has been obtained from your backend, pass it to the Wallet SDK to complete the external authentication process:

import SwiftUI
import LoginIDWalletAuth
import LoginIDAPIClient

struct PostBankLogin: View {
@State private var error: String = ""
private let lid: LoginID

init() {
self.lid = LoginID(baseUrl: "<LOGINID_BASE_URL>")
}

var body: some View {
VStack {
if !error.isEmpty {
Text(error).foregroundColor(.red)
}
}
.onAppear {
Task {
await checkFlow()
}
}
}

private func checkFlow() async {
do {
// Example only — this function is not part of the SDK.
// It represents your own logic to call your backend after a successful bank login.
// Your backend should validate the login and return a LoginID authorization token.
let walletResult = try await continueSessionFromBankRedirect()

guard let authorizationToken = walletResult.authorizationToken,
let callbackUrl = walletResult.callbackUrl else {
error = "Invalid wallet response."
return
}

// [3b-1] User signs in via fallback method
let result = try await lid.performAction(
action: .external,
options: PerformActionOptions(payload: authorizationToken)
)

if result.nextAction == MfaActionAction.Name.passkeyReg {
// Navigate to "Add Passkey" screen
// (e.g. update navigation state here)
} else {
// Continue without adding passkey to user
}
} catch let error as LoginIDError {
self.error = error.message
} catch {
self.error = error.localizedDescription
}
}
}

Once authenticated, this example navigates the user to the PasskeyCreate view. Your implementation may differ depending on how you handle routing and view structure.

[3b-2] Optionally Create a Passkey

info

You can pass in a display name during passkey creation.

The display name is a user-friendly label that helps users recognize the correct passkey from a list, like a familiar nickname.

try await lid.performAction(
.passkeyReg,
PerformActionOptions(displayName: "Wallet User")
);

This is a follow-up from the previous section. We mavigate to the PasskeyCreate view after verifying bank login and finishing external authentication.

import SwiftUI
import LoginIDWalletAuth

struct PasskeyCreate: View {
@State private var error: String = ""
private let lid: LoginID

init() {
self.lid = LoginID(baseUrl: "<LOGINID_BASE_URL>")
}

var body: some View {
VStack(spacing: 16) {
Button("Create Passkey") {
Task {
await addPasskeyHandler()
}
}
.padding()

Button("Skip for now") {
skipHandler()
}
.padding()

if !error.isEmpty {
Text(error)
.foregroundColor(.red)
}
}
.padding()
}

private func addPasskeyHandler() async {
do {
// [3b-2] Create a new passkey after bank login
let result = try await lid.performAction(action: .passkeyReg)

if let accessToken = result.accessToken {
// [3b-3] Continue to finalize payment
try await finalizeTransaction(accessToken: accessToken)
}
} catch let error as LoginIDError {
self.error = error.message
} catch {
self.error = error.localizedDescription.isEmpty
? "Passkey registration failed."
: error.localizedDescription
}
}

private func skipHandler() {
// default if user does not want to add a passkey
// (e.g. navigate back to merchant or dismiss flow)
}
}

After the passkey is optionally created, complete the flow by following the Finalizing Result to Merchant step. On future checkouts, LoginID will allow the flow to go directly to transaction confirmation.

Finalize and Return Result to Merchant

Once the transaction has been confirmed (via passkey or fallback flow), you should finalize the payment on your wallet backend and return the result to the merchant application.

Here’s an example of how you might handle this:

// Example: Finalizing transaction result and returning to merchant

func finalizeTransaction(token: String) async throws {
// Confirm the payment on your wallet backend (stubbed for example)
let result = try await walletBackend.confirmPayment(token: token)

// Do whtat you need to do here

// If backend confirms, redirect back to merchant app
if let url = URL(string: "\(result.callback)?token=\(result.token)&passkey=\(result.passkey)") {
UIApplication.shared.open(url)
}
}

You can call this after authentication or payment confirmation.

Conclusion

The Wallet SDK for iOS streamlines checkout by combining authentication and payment approval into a single flow. With beginFlow, the SDK determines the right next action — either confirming the transaction with a passkey or guiding the user through fallback login.

Passkey-based transaction confirmation serves as both signin and checkout approval in one step, delivering a fast and secure experience for returning users. Fallback methods and passkey creation ensure new or recovering users can still complete their transactions.

This provides a smooth, secure checkout that reduces friction while strengthening trust.

For additional support or clarification, you can reach us anytime at support@loginid.io.

tip

Having issues? Visit our troubleshooting section for common questions and solutions.