Keycloak Authentication for Native iOS (Swift)

Guilliano Molaire Guilliano Molaire Updated June 12, 2026 10 min read

Last updated: June 2026

Native iOS apps authenticate with Keycloak using the OAuth 2.0 authorization-code flow with PKCE through a system browser session — ASWebAuthenticationSession — typically via the AppAuth-iOS library. You must use a public client with PKCE and never embed a client secret in the app binary. This is the security model mandated by RFC 8252 (OAuth 2.0 for Native Apps).

This tutorial walks you from a blank Keycloak realm to a working Swift app that logs users in, stores tokens securely in the Keychain, refreshes silently, calls a protected API, and logs out cleanly.

Why PKCE and a Public Client Are Non-Negotiable on iOS

RFC 8252, published by the IETF, establishes that native applications must not use client secrets because any value embedded in a distributed binary can be extracted by a motivated attacker. The spec’s alternative is PKCE (Proof Key for Code Exchange), which replaces the static secret with a cryptographic challenge generated fresh for every login attempt.

The flow works like this: the app generates a random code_verifier, hashes it to produce a code_challenge, sends the challenge with the authorization request, and then proves ownership of the original verifier when exchanging the authorization code for tokens. A stolen authorization code is useless without the verifier, which never leaves the device.

AppAuth-iOS handles all of this automatically once you configure it. What you must never do is set Access Type: confidential in Keycloak and paste the client secret into your app. If you’re tempted to store a secret in an .xcconfig file or an environment variable baked into the binary at build time, read the mobile OAuth best practices overview before proceeding — it covers the full threat model.

Keycloak Public Client Setup

You need Keycloak 26.x. These steps use the Admin Console, but you can also apply them via the REST API or Terraform.

Create the Realm and Client

  1. Log in to the Keycloak Admin Console (https://your-keycloak-host/admin).
  2. Create a new realm or select an existing one. For this tutorial the realm is named myrealm.
  3. Navigate to Clients > Create client.
  4. Set Client type to OpenID Connect.
  5. Set Client ID to ios-app (or any identifier you prefer).
  6. On the next screen, set Client authentication to Off. This is what makes it a public client — Keycloak will not issue a client secret.
  7. Enable Standard flow (authorization code). Disable all other flows.

Configure the Redirect URI

iOS apps intercept the OAuth redirect through one of two mechanisms:

  • Custom URI scheme — e.g., com.example.myapp:/oauth2redirect. Simple to configure, but can be hijacked by a malicious app that registers the same scheme.
  • Universal Links (HTTPS) — e.g., https://app.example.com/oauth2redirect. Requires an apple-app-site-association file on your domain server but is phishing-resistant.

For most apps, start with a custom scheme during development and migrate to Universal Links before production. In the Keycloak client settings, add the redirect URI:

com.example.myapp:/oauth2redirect

Set Valid post logout redirect URIs to the same value so logout can return to your app.

Leave Web origins blank for native clients — CORS is a browser concern, not an iOS concern.

Adding AppAuth-iOS via Swift Package Manager

Open your Xcode project, go to File > Add Package Dependencies, and enter the repository URL:

https://github.com/openid/AppAuth-iOS

Select the latest release (1.7.x or later as of this writing) and add the AppAuth library to your app target. No Podfile or Cartfile needed.

Building the Authorization Request

Create a dedicated AuthManager class that owns the AppAuth state. Keep it as an ObservableObject if you’re using SwiftUI, or a plain class for UIKit.

import AppAuth
import AuthenticationServices

final class AuthManager: NSObject, ObservableObject {

    // Keycloak 26.x discovery document endpoint
    private let issuer = URL(string: "https://your-keycloak-host/realms/myrealm")!
    private let clientID = "ios-app"
    private let redirectURI = URL(string: "com.example.myapp:/oauth2redirect")!
    private let scopes = [OIDScopeOpenID, OIDScopeProfile, OIDScopeEmail]

    @Published var isAuthenticated = false
    private var authState: OIDAuthState?
    private var currentAuthorizationFlow: OIDExternalUserAgentSession?

    // MARK: - Login

    func login(presenting viewController: UIViewController) {
        OIDAuthorizationService.discoverConfiguration(forIssuer: issuer) { [weak self] configuration, error in
            guard let self, let configuration else {
                print("Discovery failed: (error?.localizedDescription ?? "unknown")")
                return
            }
            self.performAuthRequest(configuration: configuration, presenting: viewController)
        }
    }

    private func performAuthRequest(
        configuration: OIDServiceConfiguration,
        presenting viewController: UIViewController
    ) {
        let request = OIDAuthorizationRequest(
            configuration: configuration,
            clientId: clientID,
            clientSecret: nil,           // Public client — no secret
            scopes: scopes,
            redirectURL: redirectURI,
            responseType: OIDResponseTypeCode,
            additionalParameters: nil
        )

        // AppAuth uses ASWebAuthenticationSession internally on iOS 12+
        currentAuthorizationFlow = OIDAuthState.authState(
            byPresenting: request,
            presenting: viewController
        ) { [weak self] authState, error in
            guard let self else { return }
            if let authState {
                self.authState = authState
                self.saveToKeychain(authState)
                DispatchQueue.main.async { self.isAuthenticated = true }
            } else {
                print("Authorization error: (error?.localizedDescription ?? "unknown")")
            }
        }
    }
}

AppAuth calls ASWebAuthenticationSession internally, which opens the system browser in a modal sheet. The user authenticates on the Keycloak login page — your app never sees the credentials. The system browser shares cookies with Safari, so if the user is already signed in elsewhere, Keycloak can SSO them silently.

For a broader comparison of how this flow differs across platforms, see the React Native and Expo implementation guide and the Flutter equivalent.

Handling the Redirect Back to Your App

AppAuth needs to intercept the redirect URI before iOS hands it to Safari. Register the custom scheme in Info.plist:

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>com.example.myapp</string>
        </array>
    </dict>
</array>

Then, in your UIApplicationDelegate (or UISceneDelegate for scene-based apps), forward the incoming URL to AppAuth:

// UIApplicationDelegate approach
func application(
    _ app: UIApplication,
    open url: URL,
    options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
    if let flow = authManager.currentAuthorizationFlow,
       flow.resumeExternalUserAgentFlow(with: url) {
        authManager.currentAuthorizationFlow = nil
        return true
    }
    return false
}

For SwiftUI apps using the @UIApplicationDelegateAdaptor pattern, the same delegate method applies. The onOpenURL modifier is an alternative but less reliable for OAuth callbacks.

Exchanging the Code for Tokens

AppAuth performs the code exchange automatically inside the authState(byPresenting:) callback you saw above — you don’t need to make a separate network call. By the time your completion handler fires, authState.lastTokenResponse already contains the access token, ID token, and refresh token.

What AppAuth does under the hood:

  1. Sends a POST to https://your-keycloak-host/realms/myrealm/protocol/openid-connect/token.
  2. Includes code, code_verifier, client_id, redirect_uri, and grant_type=authorization_code.
  3. Keycloak validates the verifier against the challenge it stored and returns the token set.

To understand the full lifecycle of these tokens — including expiry and revocation signals — the JWT token lifecycle guide covers the internals in detail.

Storing Tokens in the Keychain

Never store tokens in UserDefaults. UserDefaults is unencrypted and accessible to anyone who can run idevicebackup2 against an unencrypted iTunes backup. The Keychain is encrypted at rest and protected by the Secure Enclave on devices with one.

import Security

extension AuthManager {

    private static let keychainService = "com.example.myapp.authstate"
    private static let keychainAccount = "OIDAuthState"

    func saveToKeychain(_ state: OIDAuthState) {
        guard let data = try? NSKeyedArchiver.archivedData(
            withRootObject: state,
            requiringSecureCoding: true
        ) else { return }

        let query: [String: Any] = [
            kSecClass as String:       kSecClassGenericPassword,
            kSecAttrService as String: Self.keychainService,
            kSecAttrAccount as String: Self.keychainAccount,
            kSecValueData as String:   data,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        ]

        SecItemDelete(query as CFDictionary) // Remove old value first
        SecItemAdd(query as CFDictionary, nil)
    }

    func loadFromKeychain() -> OIDAuthState? {
        let query: [String: Any] = [
            kSecClass as String:       kSecClassGenericPassword,
            kSecAttrService as String: Self.keychainService,
            kSecAttrAccount as String: Self.keychainAccount,
            kSecReturnData as String:  true,
            kSecMatchLimit as String:  kSecMatchLimitOne
        ]

        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        guard status == errSecSuccess, let data = result as? Data else { return nil }

        return try? NSKeyedUnarchiver.unarchivedObject(
            ofClass: OIDAuthState.self,
            from: data
        )
    }

    func clearKeychain() {
        let query: [String: Any] = [
            kSecClass as String:       kSecClassGenericPassword,
            kSecAttrService as String: Self.keychainService,
            kSecAttrAccount as String: Self.keychainAccount
        ]
        SecItemDelete(query as CFDictionary)
    }
}

Use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly so tokens survive app restarts but can’t be restored from an iCloud or iTunes backup. If your app has strict data-residency requirements, switch to kSecAttrAccessibleWhenUnlockedThisDeviceOnly, which requires the screen to be unlocked for token reads.

Biometrics Gating (Optional)

If you want to require Face ID or Touch ID before reading tokens out of the Keychain, add kSecAttrAccessControl with SecAccessControlCreateWithFlags using the .biometryCurrentSet flag. This ties the Keychain item to the currently enrolled biometric set — if the user re-enrolls, the item becomes inaccessible and they must re-authenticate. Use this pattern for high-assurance apps handling sensitive data.

Refreshing the Access Token Silently

Access tokens from Keycloak expire (default: 5 minutes for the access token, 30 minutes for the refresh token — both configurable per realm). AppAuth’s OIDAuthState tracks validity automatically:

extension AuthManager {

    func performAuthenticatedRequest(
        url: URL,
        completion: @escaping (Data?, URLResponse?, Error?) -> Void
    ) {
        guard let authState else {
            completion(nil, nil, NSError(domain: "AuthManager", code: -1))
            return
        }

        authState.performAction { accessToken, idToken, error in
            guard let accessToken, error == nil else {
                DispatchQueue.main.async { self.isAuthenticated = false }
                completion(nil, nil, error)
                return
            }

            var request = URLRequest(url: url)
            request.setValue("Bearer (accessToken)", forHTTPHeaderField: "Authorization")

            URLSession.shared.dataTask(with: request, completionHandler: completion).resume()
        }
    }
}

performAction checks whether the current access token is still valid. If it has expired, AppAuth automatically calls https://your-keycloak-host/realms/myrealm/protocol/openid-connect/token with the refresh token and grant_type=refresh_token, updates authState with the new token set, and then calls your closure with a fresh access token. You don’t write any of the refresh logic yourself.

Persist the updated state back to the Keychain after each refresh by subscribing to OIDAuthState‘s stateChangeDelegate:

extension AuthManager: OIDAuthStateChangeDelegate {
    func didChange(_ state: OIDAuthState) {
        saveToKeychain(state)
    }
}

Set the delegate after loading state: authState?.stateChangeDelegate = self.

For the full conceptual picture of OAuth flows on mobile, see the OAuth 2.0 developer visual guide.

Calling a Protected API

With performAuthenticatedRequest in place, every API call is a one-liner:

authManager.performAuthenticatedRequest(url: URL(string: "https://api.example.com/me")!) { data, response, error in
    guard let data, error == nil else { return }
    // Parse your response
    let user = try? JSONDecoder().decode(UserProfile.self, from: data)
    DispatchQueue.main.async {
        self.userProfile = user
    }
}

The Authorization: Bearer <token> header is attached automatically. Your API validates the token against Keycloak’s JWKS endpoint (https://your-keycloak-host/realms/myrealm/protocol/openid-connect/certs) — the app itself doesn’t need to validate JWTs.

Logout

A proper logout has two parts: clearing the local session and ending the Keycloak session on the server. Clearing only the local state leaves the SSO session alive — the user can re-authenticate without entering credentials again, which matters for shared-device scenarios.

extension AuthManager {

    func logout(presenting viewController: UIViewController) {
        guard let authState,
              let idToken = authState.lastTokenResponse?.idToken,
              let endSessionEndpoint = authState.lastAuthorizationResponse
                  .request.configuration.discoveryDocument?.endSessionEndpoint
        else {
            // Fall back to local-only logout if endpoint isn't discoverable
            localLogout()
            return
        }

        let request = OIDEndSessionRequest(
            configuration: authState.lastAuthorizationResponse.request.configuration,
            idTokenHint: idToken,
            postLogoutRedirectURL: redirectURI,
            state: UUID().uuidString,
            additionalParameters: nil
        )

        let agent = OIDExternalUserAgentIOS(presenting: viewController)!
        currentAuthorizationFlow = OIDAuthorizationService.present(
            request,
            externalUserAgent: agent
        ) { [weak self] _, _ in
            self?.localLogout()
        }
    }

    private func localLogout() {
        authState = nil
        clearKeychain()
        DispatchQueue.main.async { self.isAuthenticated = false }
    }
}

Keycloak’s end-session endpoint (/realms/myrealm/protocol/openid-connect/logout) is included in the discovery document, so AppAuth picks it up automatically. The id_token_hint parameter tells Keycloak which session to terminate without requiring another login prompt.

Running the Full Discovery

Rather than hardcoding token and authorization endpoints, always use OIDC discovery. Keycloak publishes its configuration at:

https://your-keycloak-host/realms/myrealm/.well-known/openid-configuration

OIDAuthorizationService.discoverConfiguration(forIssuer:) fetches and caches this document. If Keycloak rotates signing keys or changes endpoint paths (rare but possible across major versions), discovery ensures your app adapts automatically on the next cold start.

Managed Keycloak for Production iOS Apps

Running your own Keycloak cluster adds operational overhead: JVM tuning, database failover, certificate rotation, and upgrade management. If you’d rather focus on your iOS app than on infrastructure, Skycloak provides managed Keycloak hosting with high-availability clusters, automatic upgrades, and a dedicated support team. Plans start at a predictable monthly price with no per-MAU charges.

Frequently asked questions

Can I use AppAuth-iOS without a redirect URI custom scheme?

Yes. You can use Universal Links (HTTPS redirect URIs) instead of a custom URI scheme. This requires hosting an apple-app-site-association file at https://yourdomain.com/.well-known/apple-app-site-association with your app’s bundle ID and team ID. The Keycloak valid redirect URI must then be the HTTPS URL rather than a custom scheme. Universal Links are harder to hijack because iOS validates domain ownership, making them the better choice for production apps.

How do I handle the case where the refresh token has expired?

When performAction returns an error with code OIDErrorCodeOAuthToken and the error description includes invalid_grant, the refresh token has expired or been revoked. Catch this case, call localLogout() to clear the Keychain, and prompt the user to log in again. You can also subscribe to Keycloak’s session events via a webhook or check the Admin REST API to proactively detect invalidated sessions. The JWT lifecycle guide has a section on refresh-token revocation strategies.

Should I use a custom URI scheme or Universal Links for the redirect?

Custom URI schemes (myapp:/) are easier to set up and work in simulators without any server configuration. Universal Links (https://) require a valid HTTPS domain and the apple-app-site-association file but are phishing-resistant — no other app can intercept them. For apps that handle sensitive data (healthcare, finance), Universal Links are the right default. For development and low-risk apps, custom schemes are acceptable.

What Keycloak realm settings affect the iOS token lifetime?

In the Keycloak Admin Console under Realm Settings > Tokens, the key fields are Access Token Lifespan (default 5 minutes), Client Session Idle (default 30 minutes), and SSO Session Max (default 10 hours). Short access token lifespans are desirable — AppAuth handles the refresh automatically. If users complain about frequent full re-authentication, increase SSO Session Max rather than extending access token lifespans. For a broader look at session configuration on mobile, see the mobile OAuth best practices post.

Is AppAuth-iOS compatible with SwiftUI and the latest Xcode?

Yes. AppAuth-iOS ships as a Swift Package, builds cleanly with Xcode 16 and Swift 6, and works in both UIKit and SwiftUI projects. In a SwiftUI app, inject AuthManager as an @StateObject at the root level and pass it down via the environment. The login(presenting:) method requires a UIViewController, which you can obtain in SwiftUI using a UIViewControllerRepresentable shim or by reading from UIApplication.shared.connectedScenes. AppAuth’s maintainers (the OpenID Foundation) actively track Xcode releases, so compatibility issues are typically patched within days of a new Xcode beta.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman