All Developer Edition Orgs will be deactivated starting on July 18, 2025. Sign up for the Integrator Free Plan to continue building and integrating. Learn more on the Okta Developer blog

How to Build a Secure iOS App with MFA

How to Build a Secure iOS App with MFA

Modern mobile applications require robust security solutions, especially when handling sensitive user data or enterprise-level access. Okta offers a powerful identity platform, and with the BrowserSignIn module from its Swift SDK, adding secure login to your iOS app becomes scalable and straightforward.

In this post, you’ll learn how to:

  1. Set up your Okta developer account
  2. Configure your iOS app for authentication using best practices
  3. Customize the authentication experience with MFA policies
  4. Create an AuthService testable protocol
  5. Showcase a SwiftUI example on how to integrate the AuthService

Note: This guide assumes you’re comfortable working in Xcode with Swift.

If you want to skip the tutorial and run the project, you can follow the instructions in the project’s README.

Table of Contents

Use Okta for OAuth 2.0 and OpenID Connect (OIDC)

The first step is registering your app in Okta as an OpenID Connect (OIDC) client using Authorization Code Flow with Proof Key for Code Exchange (PKCE), the most secure and mobile-friendly OAuth 2.0 flow. PKCE is a best practice for mobile apps to prevent authorization code interception attacks.

Before you begin, you’ll need an Okta Integrator Free Plan account. To get one, sign up for an Integrator account. Once you have an account, sign in to your Integrator account. Next, in the Admin Console:

  1. Go to Applications > Applications
  2. Click Create App Integration
  3. Select OIDC - OpenID Connect as the sign-in method
  4. Select Native Application as the application type, then click Next
  5. Enter an app integration name

  6. Configure the redirect URIs:
    • Redirect URI: com.okta.{yourOktaDomain}:/callback
    • Post Logout Redirect URI: com.okta.{yourOktaDomain}:/ (where {yourOktaDomain}.okta.com is your Okta domain name). Your domain name is reversed to provide a unique scheme to open your app on a device.
  7. In the Controlled access section, select the appropriate access level
  8. Click Save

NOTE: When using a custom authorization server, you need to set up authorization policies. Complete these additional steps:

  1. In the Admin Console, go to Security > API > Authorization Servers
  2. Select your custom authorization server (default)
  3. On the Access Policies tab, ensure you have at least one policy:
    • If no policies exist, click Add New Access Policy
    • Give it a name like “Default Policy”
    • Set Assign to to “All clients”
    • Click Create Policy
  4. For your policy, ensure you have at least one rule:
    • Click Add Rule if no rules exist
    • Give it a name like “Default Rule”

    • Set Grant type is to “Authorization Code”

    • Set User is to “Any user assigned the app”
    • Set Scopes requested to “Any scopes”
    • Click Create Rule

For more details, see the Custom Authorization Server documentation.

Where are my new app's credentials?

Creating an OIDC Native App manually in the Admin Console configures your Okta Org with the application settings.

After creating the app, you can find the configuration details on the app’s General tab:

  • Client ID: Found in the Client Credentials section
  • Issuer: Found in the Issuer URI field for the authorization server that appears by selecting Security > API from the navigation pane.
Issuer:    https://dev-133337.okta.com/oauth2/default
Client ID: 0oab8eb55Kb9jdMIr5d6

NOTE: You can also use the Okta CLI Client or Okta PowerShell Module to automate this process. See this guide for more information about setting up your app.

Replace {yourOktaDomain} with your Okta domain.

Prefer phishing-resistant authentication factors

Every new Integrator Free Plan admin account must use the Okta Verify app by default to set up MFA (multi-factor authentication). We’ll retain the default settings for this project, but you can tailor the authentication policy for your organization’s needs. We recommend phishing-resistant factors, such as Okta Verify with biometrics and FIDO2 with WebAuthn. These configurations help defend against credential theft and phishing and align with Okta’s Secure Identity Commitment, standards like NIST SP 800-63, and industry regulations like SOC 2 or HIPAA.

  • Prefer MFA or phishing-resistant factors for real users
  • Tailor policies based on risk level, environment (dev vs prod), and user behavior

Thoughtfully configuring your authentication policies protects your users while maintaining a seamless login experience.

Create an iOS project with Okta’s mobile libraries for authentication

Before diving into integration, ensure you have the following prerequisites:.

  • Xcode version 15.0 or later. This guide assumes you’re comfortable working in Xcode and building iOS apps in Swift.
  • Swift - This guide uses Swift 5+ features.
  • Swift Package Manager (SPM) - We’ll use Swift Package Manager for managing dependencies. Ensure it’s available in Xcode.
  • Node and npm installed locally to run the backend server

Creating your Xcode project

If you are starting from scratch, create a new iOS app:

  1. Open Xcode
  2. Go to File -> New -> Project
  3. Select iOS App and select Next
  4. Enter the name of the project
  5. Set the Interface to SwiftUI or UIKit, depending on your preference

    In this post, we will be using SwiftUI

  6. Select Next and save your project locally

You’re now ready to add Okta’s SDK into your project.

Authenticate your iOS app using OpenID Connect (OIDC) and OAuth 2.0 with Okta

To integrate the Okta SDK into your iOS app, follow these detailed steps using Swift Package Manager (SPM), the recommended and modern way to manage dependencies in Xcode.

Follow these steps:

  1. Open the project if it’s not already open
  2. Select FileAdd Package Dependencies
  3. In the search bar at the top right of the window that appears, add the https://github.com/okta/okta-mobile-swift repository and select Enter. Xcode will fetch the package details.
  4. Choose the latest version available (recommended) or the version you prefer.
  5. When prompted to choose the products to add, make sure to select your project next to BrowserSignin in the Add to Target column
  6. Select Add Package

This package provides the full login UI experience and token handling utilities for OAuth 2.0 with PKCE. It’s the core component for authentication in your iOS app.

Once added, you’ll see the Okta SDK listed under your project’s Package Dependencies.

Add the OIDC configuration to your iOS app

To use the OktaBrowserSignin flow, initialize the shared client with your specific app credentials.

The cleanest and most scalable way to manage configuration is to use a property list file for Okta stored in your app bundle.

Create the property list for your OIDC and app config by following these steps:

  1. Right-click on the root folder of the project
  2. Select New File from Template (New File in legacy Xcode versions)
  3. Ensure you have iOS selected on the top picker
  4. Select Property List template and select Next
  5. Name the template Okta and select Create to create a Okta.plist file

You can edit the file in XML format by right-clicking and selecting Open As -> Source Code. Copy and paste the following code into the file.

<?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>scopes</key>
    <string>openid profile offline_access</string>
    <key>redirectUri</key>
    <string>com.okta.{yourOktaDomain}:/callback</string>
    <key>clientId</key>
    <string>{yourClientID}</string>
    <key>issuer</key>
    <string>{yourOktaDomain}/oauth2/default</string>
    <key>logoutRedirectUri</key>
    <string>com.okta.{yourOktaDomain}:/</string>
</dict>
</plist>

Replace {yourOktaDomain} and {yourClientID} with the values from your Okta org.

If you use something like this now in code, you can directly access the BrowserSignin shared object, which will already be allocated and ready for use.

Manage authentication actions for your iOS app using the Okta Swift SDK

We’ll build the core authentication layer for our app, the AuthService. This service handles login, logout, token refresh, and user info retrieval using the OktaBrowserSignin module.

Create a new folder named Auth under your project’s folder structure. We’ll use this folder to organize our authentication code. Inside that folder, create a new Swift file named AuthService.swift and define the protocol and class:

import BrowserSignin
protocol AuthServiceProtocol {
    var isAuthenticated: Bool { get }
    var idToken: String? { get }
    
    func tokenInfo() -> TokenInfo?
    func userInfo() async throws -> UserInfo?
    func signIn() async throws
    func signOut() async throws
    func refreshTokenIfNeeded() async throws
}
final class AuthService: AuthServiceProtocol {
    // Implementation will go here
}

After doing this, you will get an error message saying that the AuthService does not conform to protocol AuthServiceProtocol because we haven’t implemented the functions yet. We will implement the functions as we progress.

Create a folder named Models inside the Auth folder. Within the Models folder, create a new file named TokenInfo.swift, and add the code shown:

struct TokenInfo {
    // we will add properties in the next section
}

Next, we will add the signIn and signOut methods inside the AuthService class. With the Okta Swift SDK, handling user authentication is straightforward and secure – thanks to the built-in signIn and signOut methods in the BrowserSignin client. Let’s break down how to build these methods in your AuthService.

The signIn method

The signIn method redirects the user to authenticate using Okta, handles the PKCE flow, and retrieves the authentication tokens upon successful login. Open the AuthService class, find the comment Implementation will go here, replace the comment with the following code:

@MainActor
func signIn() async throws {
    BrowserSignin.shared?.ephemeralSession = true
    let tokens = try await BrowserSignin.shared?.signIn()
    if let tokens {
      _ = try? Credential.store(tokens)
    }
}

Let’s unpack this:

BrowserSignin.shared?.ephemeralSession = true

This property controls the type of browser session used for authentication:

  • If set to true, it forces an ephemeral browser session, meaning no cookies or session state will persist across authentication attempts. It’s like opening a private/incognito window for each login attempt.
  • If set to false, it shares the browser state with the system browser, allowing Okta to remember the user’s login state across sessions (for example, for single sign-on across apps).

In our demo, we set ephemeralSession = true to treat each login as a fresh authentication, which is ideal for testing.

  • signIn(from: window) This function launches the Okta-hosted sign-in page. The window parameter provides context for where to present the login UI, typically your app’s current window if building in UIKit.
  • Credential.store(tokens) After login, we store the tokens securely (e.g., access token, ID token, and refresh token) using Okta’s built-in Credential storage helper.

The signOut method

Signing out is also straightforward. We will proceed by adding it immediately below the signIn method in the AuthService class:

@MainActor
func signOut() async throws {
  guard let credential = Credential.default else { return }
  try await BrowserSignin.shared?.signOut(token: credential.token)
  try? credential.remove()
}

Here’s what happens:

  • We check if there’s a current credential by calling Credential.default.
  • We call signOut on the shared BrowserSignIn instance, passing the current token for session revocation.
  • After a successful logout, we remove the credential from secure storage.

This ensures the user’s session is entirely revoked and cleared from the app and Okta’s backend.

Add handling for OAuth 2.0 and OIDC tokens and the authenticated session

Once we’ve set up authentication flows, we must handle token management and session state. This step ensures that your app knows when the user is authenticated, how to access their tokens, and how to refresh tokens when needed.

The protocol requires implementing two computed variables and three functions to help us manage the tokens and the session.

Add the following code in the implementation of the AuthService class right above the signIn method:

var isAuthenticated: Bool {
  return Credential.default != nil
}

Let’s go through the code.

The isAuthenticated computed property checks whether there’s a valid token stored in the app:

  • It uses Credential.default, a singleton that securely stores the user’s tokens.
  • If a valid token exists, the user is considered authenticated; otherwise, they are not.

Next, we’ll add the second helper computed property, which we will use to retrieve the user’s ID token. In the AuthService class, under the isAuthenticated property, add the following code:

var idToken: String? {
  return Credential.default?.token.idToken?.rawValue
}

The idToken property retrieves the raw value of the ID token from the stored credential:

  • The ID token is a signed JSON Web Token (JWT) containing user identity information, such as the user’s email, name, and subject (sub).

We successfully implemented the computed properties required by the protocol. Next, we’ll add the implementation for the three helper functions.

Tokens always expire, which means that at some point, they are no longer valid, and we must refresh them. Lucky, Okta’s SDK provides us with a solution for this need. We can leverage the refresh function, which is part of the Credential object.

Inside the AuthService class, right after the signOut method, add the refreshTokenIfNeeded() function:

func refreshTokenIfNeeded() async throws {
  guard let credential = Credential.default else { return }
  try await credential.refresh()
}

The refreshTokenIfNeeded method ensures that tokens are up-to-date by attempting a token refresh when necessary:

  • It calls the Credential.refresh() method, which uses the refresh token (if available) to get a new access token and ID token.
  • This helps avoid token expiration issues that could interrupt the user’s session.

At this point, we’ll add an empty implementation to the other two functions, which will help us get some information about the token and the user. In our case, we will present some data on the screen. Add the following code after the refreshTokenIfneeded() function:

func tokenInfo() -> TokenInfo? {
  return nil
}
    
func userInfo() async throws -> UserInfo? {
  return nil
}

With this added, we resolved the errors we saw in AuthService, and you’ll be able to build the project successfully.

Use the auth service in your Swift app

Now that we’ve built the AuthService to handle sign in, sign out, token management, and user info retrieval, let’s see how to integrate it into your app’s UI.

Use AuthService in your views

Since this app is about authentication, rename the auto-generated view ContentView to AuthView and rename the file to match. Don’t forget to rename all the existing and auto-generated references to ContentView and use AuthView instead.

Next, in the same folder as the AuthView, we will create the AuthViewModel. The AuthViewModel handles all user actions and authentication:

import Foundation
import Observation
import BrowserSignin

@Observable
final class AuthViewModel {
    // MARK: - Dependencies
    
    /// This is the service that handles all the sign-in, sign-out, token, and user info logic.
    private let authService: AuthServiceProtocol

    // MARK: - UI State Properties

    /// True if the user is currently logged in.
    var isAuthenticated: Bool = false
    
    /// The user's ID token (used for secure backend communication).
    var idToken: String?
    
    /// Shows a loading spinner while something is happening in the background.
    var isLoading: Bool = false
    
    /// If something goes wrong (e.g., login fails), the error message will show in the UI.
    var errorMessage: String?
    
    /// This holds a message returned from the resources server.
    var serverMessage: String?

    // MARK: - Initialization

    
    /// Create the view model and immediately update the UI with the current authentication status.
    init(authService: AuthServiceProtocol = AuthService()) {
        self.authService = authService
        updateUI()
    }
    
    // MARK: - UI State Management

    /// Updates the `isAuthenticated` and `idToken` values from the authentication service.
    func updateUI() {
        isAuthenticated = authService.isAuthenticated
        idToken = authService.idToken
    }
    
    // MARK: - Authentication
    
    /// Called when the user taps the "Sign In" or "Sign Out" button.
    /// Signs the user in or out, updates the UI, and handles any errors.
    @MainActor
    func handleAuthAction() async {
        setLoading(true)
        defer { setLoading(false) }

        do {
            if isAuthenticated {
                // User is signed in → sign them out
                try await authService.signOut()
            } else {
                // User is signed out → sign them in
                try await authService.signIn()
            }
            updateUI()
        } catch {
            errorMessage = error.localizedDescription
        }
    }
    
    // MARK: - Token Handling
    
    /// Refreshes the user's token if it's about to expire.
    /// Keeps the user logged in longer without needing to manually sign in again.
    @MainActor
    func refreshToken() async {
        setLoading(true)
        defer { setLoading(false) }

        do {
            try await authService.refreshTokenIfNeeded()
            updateUI()
        } catch {
            errorMessage = error.localizedDescription
        }
    }
    
    // MARK: - User Info
    
    /// Requests user information (like name, email, etc.) from the authentication service.
    @MainActor
    func fetchUserInfo() async -> UserInfo? {
        do {
            let userInfo = try await authService.userInfo()
            return userInfo
        } catch {
            errorMessage = error.localizedDescription
            return nil
        }
    }

    // MARK: - Token Info

    /// Retrieves token metadata like expiry time or claims.
    /// Returns nil if no token is available.
    func fetchTokenInfo() -> TokenInfo? {
        guard let tokenInfo = authService.tokenInfo() else { return nil }
        return tokenInfo
    }

    // MARK: - Helpers
    
    /// Sets the loading state (used to show/hide a spinner in the UI).
    private func setLoading(_ value: Bool) {
        isLoading = value
    }
}

Next, we must extend the AuthView to use the view model and all the properties and functions we added. This view will change depending on whether the user is authenticated and will incorporate displaying the ID token and a button to refresh the token. Open AuthView.swift and replace the code with the following.

import SwiftUI
import BrowserSignin

/// The main authentication screen that shows the current login state,
/// allows the user to sign in or out, and access token/user info and server message.
struct AuthView: View {
    // View model manages all auth logic and state
    @State private var viewModel = AuthViewModel()

    // Presentation control flags for full-screen modals
    @State private var showTokenInfo = false
    
    // Holds the fetched user info data when available
    // And presents the UserInfoView when assigned value
    @State private var userInfo: UserInfoModel?

    var body: some View {
        VStack(spacing: 20) {
            statusSection
            tokenSection
            authButton
            if viewModel.isAuthenticated {
                refreshTokenButton
            }
            if viewModel.isLoading {
                ProgressView()
            }
        }
        .padding()
        .onAppear {
            // Sync UI state on view load
            viewModel.updateUI()
        }
        .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
            Button("OK", role: .cancel) {
                viewModel.errorMessage = nil
            }
        } message: {
            // Show error message if available
            if let message = viewModel.errorMessage {
                Text(message)
            }
        }
    }
}

private extension AuthView {
    /// Displays "Logged In" or "Logged Out" depending on current state.
    var statusSection: some View {
        Text(viewModel.isAuthenticated ? "✅ Logged In" : "🔒 Logged Out")
            .font(.system(size: 24, weight: .medium))
            .multilineTextAlignment(.center)
    }

    /// Shows the user's ID token in small text (only when authenticated).
    var tokenSection: some View {
        Group {
            if let token = viewModel.idToken, viewModel.isAuthenticated {
                Text("ID Token:\n\(token)")
                    .font(.system(size: 12))
                    .multilineTextAlignment(.center)
            }
        }
    }

    /// Main login/logout button. Text and action change based on login state.
    var authButton: some View {
        Button(viewModel.isAuthenticated ? "Sign Out" : "Sign In") {
            Task { await viewModel.handleAuthAction() }
        }
        .buttonStyle(.borderedProminent)
        .disabled(viewModel.isLoading)
    }

    /// Opens the full-screen view showing token info.
    var refreshTokenButton: some View {
        Button("🔄 Refresh Token") {
            Task { await viewModel.refreshToken() }
        }
        .font(.system(size: 14))
        .disabled(viewModel.isLoading)
    }
}

struct UserInfoModel: Identifiable {
    let id = UUID()
    let user: UserInfo
}

With this in place, you can run the application and test the authentication. Currently, we are not using the TokenInfo and the UserInfo from the ViewModel because we will expand the view in the next section.

Read token info

After successfully authenticating a user, it’s helpful to extract meaningful details from the ID token and present them in a user-friendly format. For this purpose, we created a TokenInfo model in the previous sections. It will be initialized from the ID token and includes a toString() function to generate a nicely formatted description of the token data for display in the UI.

Open TokenInfo.swift and add the code shown.

import Foundation
import BrowserSignin

struct TokenInfo {
    var idToken: String
    var tokenIssuer: String
    var preferredUsername: String
    var authTime: String?
    var issuedAt: String?
    
    init?(idToken: JWT) {
        guard let idToken = Credential.default?.token.idToken else {
            return nil
        }
        
        self.idToken = idToken.rawValue
        self.tokenIssuer = idToken.issuer ?? "No Issuer found"
        self.preferredUsername = idToken.preferredUsername ?? "No preferred_username found"
        
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        formatter.timeStyle = .medium
        
        if let authTime = idToken.authTime {
            self.authTime = formatter.string(from: authTime)
        }
        
        if let issuedAt = idToken.issuedAt {
            self.issuedAt = formatter.string(from: issuedAt)
        }
    }
    
    func toString() -> String {
        var result = ""
     
        result.append("ID Token: \(idToken)")
        result.append("\n")
        result.append("Preffered username: \(preferredUsername)")
        result.append("\n")
        result.append("Token Issuer: \(tokenIssuer)")
        result.append("\n")
        if let authTime {
            result.append("Auth time: \(authTime)")
            result.append("\n")
        }
        
        if let issuedAt {
            result.append("Issued at: \(issuedAt)")
            result.append("\n")
        }

        return result
    }
}

In the previous sections, we introduced two methods for fetching information about the token and the authenticated user. However, we left their implementation empty. It’s now time to implement those functions, and we will start by implementing the tokenInfo() function.

Navigate to your AuthService class and in there find the tokenInfo() function, which should look something like this:

func tokenInfo() -> TokenInfo? {
  return nil
}

To initialize our TokenInfo modal view, we need the ID token. Okta’s Swift SDK lets us fetch the ID token directly from the Credential.default. Remove the return nil from the function implementation and add the following code:

func tokenInfo() -> TokenInfo? {
  guard let idToken = Credential.default?.token.idToken else {
    return nil
  }
      
  return TokenInfo(idToken: idToken)
}

This implementation extracts the ID token from the default Credential and tries to instantiate the TokenInfo object.

Next, we must implement the second empty function introduced in the previous sections, userInfo(). We’ll use the SDK’s UserInfo model to pass the data around. Replace the existing implementation of userInfo() with the code shown.

func userInfo() async throws -> UserInfo? {
  if let userInfo = Credential.default?.userInfo {
    return userInfo
  } else {
    do {
      guard let userInfo = try await Credential.default?.userInfo() else {
        return nil
      }
      return userInfo
    } catch {
      return nil
    }
  }
}

If your Okta setup includes them, you could extend this method to extract more claims, such as email, given name, family name, or custom claims.

With this code in place, we need to display the information to the user somehow in the UI. First, we’ll create a TokenInfoView to display all the information we fetched previously. Create a new Swift file in the root folder of your application and name it TokenInfoView.swift. After creating the file, add the following code:

import SwiftUI

struct TokenInfoView: View {
  let tokenInfo: TokenInfo
  @Environment(\.dismiss) var dismiss

  var body: some View {
    ScrollView {
      VStack(alignment: .leading, spacing: 20) {
        Button {
          dismiss()
        } label: {
            Image(systemName: "xmark.circle.fill")
            .resizable()
            .foregroundStyle(.black)
            .frame(width: 40, height: 40)
            .padding(.leading, 10)
          }
                
          Text(tokenInfo.toString())
          .font(.system(.body, design: .monospaced))
          .padding()
          .frame(maxWidth: .infinity, alignment: .leading)
      }
         
    }
    .background(Color(.systemGroupedBackground))
    .navigationTitle("Token Info")
    .navigationBarTitleDisplayMode(.inline)
  }
}

Proceed with adding one more Swift file named UserInfoView.swift. This view displays previously fetched information about the User. In your newly created file, add the following code:

import SwiftUI
import BrowserSignin

struct UserInfoView: View {
  let userInfo: UserInfo
  @Environment(\.dismiss) var dismiss
    
  var body: some View {
    ScrollView {
      VStack(alignment: .leading, spacing: 20) {
        Button {
          dismiss()
        } label: {
            Image(systemName: "xmark.circle.fill")
              .resizable()
              .foregroundStyle(.black)
              .frame(width: 40, height: 40)
              .padding(.leading, 10)
          }
                
        Text(formattedData)
          .font(.system(size: 14))
          .frame(maxWidth: .infinity, alignment: .leading)
          .padding()
      }
    }
      .background(Color(.systemBackground))
      .navigationTitle("User Info")
      .navigationBarTitleDisplayMode(.inline)
  }
    
  private var formattedData: String {
    var result = ""
     
    result.append("Name: " + (userInfo.name ?? "No Name set"))
    result.append("\n")
    result.append("Username: " + (userInfo.preferredUsername ?? "No Username set"))
    result.append("\n")
    if let updatedAt = userInfo.updatedAt {
      let dateFormatter = DateFormatter()
      dateFormatter.dateStyle = .medium
      dateFormatter.timeStyle = .short
      let date = dateFormatter.string(for: updatedAt)
        result.append("Updated at: " + (date ?? ""))
      }

      return result
    }
}

Finally, we need to add some actions to the AuthView to see the views we just created. In the AuthView class at the end of the file, you will find the private extension that we previously defined. After the refreshTokenButton in the private extension of AuthView, add the following buttons:

/// Opens the full-screen view showing token info.
var tokenInfoButton: some View {
  Button {
    showTokenInfo = true
  } label: {
      Image(systemName: "info.circle")
        .foregroundColor(.blue)
    }
    .disabled(viewModel.isLoading)
  }

  /// Loads user info and presents it full screen.
  var userInfoButton: some View {
    Button("👤 User Info") {
      Task {
        if let user = await viewModel.fetchUserInfo() {
          await MainActor.run {
            userInfo = UserInfoModel(user: user)
          }
        }
      }
    }
    .font(.system(size: 14))
    .disabled(viewModel.isLoading)
}

Now that we have the buttons implemented, we need to add them to the body of AuthView so that the user can see them and click them. Scroll to the top of the file and find struct AuthView:View. Add both buttons right after refreshTokenButton, and then the VStack in your body should look like this:

VStack(spacing: 20) {
  statusSection
  tokenSection
  authButton
  if viewModel.isAuthenticated {
    refreshTokenButton
    tokenInfoButton // tokenInfoButton added here
    userInfoButton // userInfoButton added here
  }
  
  if viewModel.isLoading {
    ProgressView()
  }
}

Within the definition for body, we need to call two view modifiers to be able to see the TokenInfoView and UserInfoView. You’ll add the following code right after the closing brace of the message: {} property:

// Show Token Info full screen
.fullScreenCover(isPresented: $showTokenInfo) {
  if let tokenInfo = viewModel.fetchTokenInfo() {
    TokenInfoView(tokenInfo: tokenInfo)
  }
}
// Show User Info full screen   
.fullScreenCover(item: $userInfo) { info in
  UserInfoView(userInfo: info.user)
}

Now, if you run the application, you should be able to click on the Info button to get the token information and the User Info button to get the user information.

And there you have it! 🎉 We built a sample app from scratch using Okta’s new Swift SDK and the BrowserSignin module to show the authenticated user’s ID claims. By following these steps, you’ve learned how to:

  • ✅ Configure Okta and set up your application
  • ✅ Implement a robust AuthService to handle login, logout, and token management
  • ✅ Build a SwiftUI interface that displays user info and handles authentication flows seamlessly

With just a few lines of code, you have a fully functional, secure login flow integrated into your iOS app – no more OAuth headaches or token handling nightmares.

Authentication is the first step in an app, but we want to display data from a backend resource securely.

Add backend authorization using a custom resource server

If you want to go beyond authentication and add authorization checks for your APIs, we can experiment using Okta’s Node.js Resource Server example as a starting point.

Here’s how to connect your iOS app to a backend that validates access tokens:

Set up a customer resource server for your mobile app

Clone the example Node.js resource server:

git clone https://github.com/okta/samples-nodejs-express-4.git
cd samples-nodejs-express-4
npm ci

Open the project in an IDE like Visual Studio Code. I like Visual Studio Code because it has a built-in terminal, but you can make the required code changes directly to the file. Open resource-server/server.js. Look for the configuration block where oktaJwtVerifier is initialized. Update it like this:

const oktaJwtVerifier = new OktaJwtVerifier({
  issuer: 'https://{yourOktaDomain}/oauth2/default',
  clientId: '{yourClientID}',
});

Replace the {yourOktaDomain} with your Okta org domain, and replace the {yourClientID} with the client ID of your iOS project.

Serve the resource server by running the following command in the terminal.

npm run resource-server

You should see your server running locally at: http://localhost:8000/

This server will validate incoming access tokens and respond with two messages if the token is valid:

{
   "messages":[
      {
         "date":"2025-07-03T19:06:59.799Z",
         "text":"I am a robot."
      },
      {
         "date":"2025-07-03T18:06:59.799Z",
         "text":"Hello, world!"
      }
   ]
}

Let’s create the model conforming to this payload.

Create a file named MessagesResponse.swift in the Auth/Models folder and add the code.

import Foundation

struct MessageResponse: Codable {
   let messages: [Message]
}

struct Message: Codable {
   let date: String
   let text: String
}

Make authorized API requests from your iOS app

To call the resource server API from our iOS code, we must first implement a function inside our AuthService to fetch messages.

Open the AuthService file and add one more function at the end of the AuthServiceProtocol:

protocol AuthServiceProtocol {
  var isAuthenticated: Bool { get }
  var idToken: String? { get }
  
  func tokenInfo() -> TokenInfo?
  func userInfo() async throws -> UserInfo?
  func signIn() async throws
  func signOut() async throws
  func refreshTokenIfNeeded() async throws
  func fetchMessageFromBackend() async throws -> String // added
}

Because we introduced a new function to the protocol, it requires an implementation. In the AuthService class, immediately after the implementation of userInfo(), add the following code:

@MainActor
func fetchMessageFromBackend() async throws -> String {
  guard let credential = Credential.default else {
      return "Not authenticated."
  }

  var request = URLRequest(url: URL(string: "http://localhost:8000/api/messages")!)
  request.httpMethod = "GET"

  await credential.authorize(&request)

  let (data, _) = try await URLSession.shared.data(for: request)
  let decoder = JSONDecoder()
  let response = try decoder.decode(MessageResponse.self, from: data)
  if let randomMessage = response.messages.randomElement() {
      return "\(randomMessage.text)"
  } else {
      return "No messages found."
  }
}

With this, you will get an error message that some classes aren’t found. That’s because we must import Foundation into our AuthService.swift file just below import BrowserSignIn.

import BrowserSignin
import Foundation // added

Okta’s iOS SDK provides a handy method for automatically adding your access token as an Authorization header on a URL request.

We need to go back to the AuthViewModel and add a function to call fetchMessageFromBackend() and set the server message to our serverMessage property of the viewModel. Add the following code right after fetchTokenInfo():

// MARK: - Server Messages

/// Asks the backend for a message and saves it for display in the UI.
@MainActor
func fetchMessage() async {
  setLoading(true)
  defer { setLoading(false) }
  
  do {
    let message = try await authService.fetchMessageFromBackend()
    serverMessage = message
  } catch {
    errorMessage = error.localizedDescription
  }
}

We need to extend the AuthView to use this function and show the fetched server message as an alert to the user. For this purpose, go to the AuthView file and in the extension just below the userInfoButton, we will add one more button like this:

/// Requests a message from the backend and shows it in the UI.
var getMessageButton: some View {
  Button("🎁 Get Message") {
    Task {
        await viewModel.fetchMessage()
        
    }
  }
  .font(.system(size: 14))
  .disabled(viewModel.isLoading)
}

Next, we need to present this button to the view. In the bodyof AuthView, let’s add getMessageButton and the body will look like this:

VStack(spacing: 20) {
  statusSection
  tokenSection
  authButton
  if viewModel.isAuthenticated {
    refreshTokenButton
    tokenInfoButton
    userInfoButton
      getMessageButton // getMessageButton added here
  }
  if viewModel.isLoading {
    ProgressView()
  }
}

Lastly, we’ll alert the user with the message contents received from our backend if the authentication is successful. To do so, we need to add the .alert view modifier to the body of AuthView after the final fullScreenCover closing bracket, like this:

// Show Alert with the fetched message
.alert("Message Response", isPresented: .constant(viewModel.serverMessage != nil)) {
    Button("OK", role: .cancel) {
        viewModel.serverMessage = nil
    }
} message: {
    // Show message if available
    if let message = viewModel.serverMessage {
        Text(message)
    }
}

With all this in place, you’ll see a message alert when pressing the Get Messages button.

This is the recommended approach for securely connecting your mobile app to backend APIs using OAuth 2.0 and JWT validation. You can find the completed project in a GitHub repo.

🎉 And that’s it! Your iOS app now has authentication and calls a backend API with the access token for fully integrated authorization verification.

Check out these resources about iOS, building secure mobile apps, and Okta mobile SDKs

If you found this post interesting, you may want to check out these resources:

Follow OktaDev on Twitter and subscribe to our YouTube channel to learn about secure authentication and other exciting content. We also want to hear from you about topics you want to see and questions you may have. Leave us a comment below!

Okta Developer Blog Comment Policy

We welcome relevant and respectful comments. Off-topic comments may be removed.