OAuth for Mobile Apps: Best Practices with Keycloak

Guilliano Molaire Guilliano Molaire Updated June 15, 2026 11 min read

Last updated: March 2026

Mobile apps present unique security challenges for OAuth implementations. Unlike web browsers, mobile apps cannot securely store client secrets, they run on user-controlled devices, and they must handle complex app lifecycle events like backgrounding, memory pressure, and network interruptions. RFC 8252 (OAuth 2.0 for Native Apps) and the OAuth 2.0 Security Best Current Practice (BCP) provide the framework for doing this correctly.

This guide covers how to implement OAuth for mobile apps with Keycloak, including working iOS (Swift) and Android (Kotlin) code, secure token storage patterns, and Keycloak client configuration.

Why Mobile OAuth Is Different

Desktop and mobile apps are considered “public clients” in OAuth terminology. They cannot keep a client secret confidential because the app binary is distributed to end users. Anyone can decompile an app and extract embedded secrets. This single fact drives most of the differences in mobile OAuth implementation.

Key constraints:

  • No client secrets: The Authorization Code flow must work without a secret
  • System browser required: Embedded WebViews are prohibited by RFC 8252 because the app can intercept credentials
  • Custom redirect handling: The app needs to capture the authorization code via a redirect URI scheme
  • Insecure storage environment: The device filesystem is accessible if the device is compromised
  • Network unreliability: Token refresh must handle offline scenarios gracefully

PKCE: The Non-Negotiable Requirement

Proof Key for Code Exchange (PKCE, RFC 7636) is mandatory for mobile OAuth. The Keycloak documentation on client configuration covers the admin console settings in detail. Without PKCE, an attacker who intercepts the authorization code (via a malicious app registered for the same custom URL scheme) can exchange it for tokens.

PKCE works by having the app generate a random code_verifier, derive a code_challenge from it, and send the challenge with the authorization request. When exchanging the code for tokens, the app sends the original verifier. The authorization server verifies they match. An attacker who intercepts only the code cannot complete the exchange without the verifier.

Keycloak Client Configuration for Mobile

In the Keycloak Admin Console, create a client with these settings:

Setting Value
Client ID mobile-app
Client type OpenID Connect
Client authentication Off (public client)
Standard flow Enabled
Direct access grants Disabled
Valid redirect URIs io.skycloak.app:/callback and https://app.example.com/callback
Web origins + (allow all redirect URI origins)
Proof Key for Code Exchange S256

You can generate a full Keycloak client configuration using the Keycloak Config Generator to get a head start, then adjust the settings for mobile.

In the Keycloak Admin Console under Realm Settings > Tokens, configure:

  • Access token lifespan: 5 minutes (short-lived for mobile)
  • Refresh token lifespan: 30 days (or per your session policy)
  • Refresh token reuse detection: Enabled

For more on session management configuration, see our session management feature guide.

Redirect URI Strategies: Custom Schemes vs Universal Links

RFC 8252 describes two approaches for capturing the authorization code redirect on mobile. Each has trade-offs.

Custom URL Schemes

The app registers a custom scheme like io.skycloak.app:/callback. After authentication, the browser redirects to this scheme, which the OS routes to your app.

Pros: Simple to implement, works on all OS versions.

Cons: Another app can register the same scheme (scheme hijacking). On iOS, if two apps register the same scheme, the OS behavior is undefined.

Universal Links (iOS) / App Links (Android)

The app registers an HTTPS URL like https://app.example.com/callback and proves ownership of the domain via a configuration file (.well-known/apple-app-site-association on iOS, .well-known/assetlinks.json on Android).

Pros: Cryptographically tied to your domain. No scheme hijacking possible.

Cons: Requires domain ownership and server configuration. Can fail if the association file is not properly configured.

Recommendation: Use Universal Links / App Links for production. Fall back to custom schemes only for development.

Keycloak Redirect URI Configuration

Register both schemes in your Keycloak client’s valid redirect URIs:

io.skycloak.app:/callback
https://app.example.com/callback

iOS Implementation (Swift)

The recommended approach on iOS uses ASWebAuthenticationSession, which launches the system browser in an in-app sheet, preventing credential interception.

PKCE and Authorization Request

import AuthenticationServices
import CryptoKit

class KeycloakAuthManager: NSObject, ASWebAuthenticationPresentationContextProviding {

    private let keycloakBaseURL = "https://auth.example.com"
    private let realm = "my-app"
    private let clientID = "mobile-app"
    private let redirectURI = "https://app.example.com/callback"
    private var codeVerifier: String?

    func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
        return UIApplication.shared.connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .flatMap { $0.windows }
            .first { $0.isKeyWindow }!
    }

    // Generate a cryptographically random code verifier
    private func generateCodeVerifier() -> String {
        var bytes = [UInt8](repeating: 0, count: 32)
        _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
        return Data(bytes).base64EncodedString()
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .replacingOccurrences(of: "=", with: "")
    }

    // Derive the S256 code challenge
    private func generateCodeChallenge(from verifier: String) -> String {
        let data = Data(verifier.utf8)
        let hash = SHA256.hash(data: data)
        return Data(hash).base64EncodedString()
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .replacingOccurrences(of: "=", with: "")
    }

    func login() {
        let verifier = generateCodeVerifier()
        self.codeVerifier = verifier
        let challenge = generateCodeChallenge(from: verifier)

        var components = URLComponents(
            string: "(keycloakBaseURL)/realms/(realm)/protocol/openid-connect/auth"
        )!
        components.queryItems = [
            URLQueryItem(name: "client_id", value: clientID),
            URLQueryItem(name: "response_type", value: "code"),
            URLQueryItem(name: "scope", value: "openid profile email offline_access"),
            URLQueryItem(name: "redirect_uri", value: redirectURI),
            URLQueryItem(name: "code_challenge", value: challenge),
            URLQueryItem(name: "code_challenge_method", value: "S256"),
            URLQueryItem(name: "state", value: UUID().uuidString)
        ]

        let session = ASWebAuthenticationSession(
            url: components.url!,
            callbackURLScheme: "https"
        ) { [weak self] callbackURL, error in
            guard let url = callbackURL, error == nil else {
                print("Auth failed: (error?.localizedDescription ?? "unknown")")
                return
            }
            self?.handleCallback(url: url)
        }

        session.presentationContextProvider = self
        session.prefersEphemeralWebBrowserSession = false
        session.start()
    }

    private func handleCallback(url: URL) {
        guard let code = URLComponents(url: url, resolvingAgainstBaseURL: false)?
            .queryItems?.first(where: { $0.name == "code" })?.value,
              let verifier = codeVerifier else {
            return
        }

        exchangeCodeForTokens(code: code, verifier: verifier)
    }

    private func exchangeCodeForTokens(code: String, verifier: String) {
        let tokenURL = URL(
            string: "(keycloakBaseURL)/realms/(realm)/protocol/openid-connect/token"
        )!
        var request = URLRequest(url: tokenURL)
        request.httpMethod = "POST"
        request.setValue(
            "application/x-www-form-urlencoded",
            forHTTPHeaderField: "Content-Type"
        )

        let body = [
            "grant_type": "authorization_code",
            "client_id": clientID,
            "code": code,
            "redirect_uri": redirectURI,
            "code_verifier": verifier
        ]
        request.httpBody = body
            .map { "($0.key)=($0.value)" }
            .joined(separator: "&")
            .data(using: .utf8)

        URLSession.shared.dataTask(with: request) { data, response, error in
            guard let data = data,
                  let tokens = try? JSONDecoder().decode(TokenResponse.self, from: data) else {
                return
            }
            // Store tokens securely (see next section)
            KeychainTokenStore.shared.save(tokens: tokens)
        }.resume()
    }
}

struct TokenResponse: Codable {
    let accessToken: String
    let refreshToken: String?
    let idToken: String?
    let expiresIn: Int
    let tokenType: String

    enum CodingKeys: String, CodingKey {
        case accessToken = "access_token"
        case refreshToken = "refresh_token"
        case idToken = "id_token"
        case expiresIn = "expires_in"
        case tokenType = "token_type"
    }
}

Secure Token Storage with Keychain

Never store tokens in UserDefaults, files, or any other unencrypted storage. iOS Keychain provides hardware-backed encryption:

import Security

class KeychainTokenStore {
    static let shared = KeychainTokenStore()
    private let service = "io.skycloak.app.tokens"

    func save(tokens: TokenResponse) {
        save(key: "access_token", value: tokens.accessToken)
        if let refresh = tokens.refreshToken {
            save(key: "refresh_token", value: refresh)
        }
        if let id = tokens.idToken {
            save(key: "id_token", value: id)
        }
    }

    private func save(key: String, value: String) {
        let data = value.data(using: .utf8)!

        // Delete existing item first
        let deleteQuery: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key
        ]
        SecItemDelete(deleteQuery as CFDictionary)

        // Add new item with biometric protection
        let addQuery: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
        ]
        SecItemAdd(addQuery as CFDictionary, nil)
    }

    func load(key: String) -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            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 String(data: data, encoding: .utf8)
    }
}

Android Implementation (Kotlin)

On Android, the recommended library is AppAuth for Android, which handles PKCE, browser selection, and the authorization flow.

Gradle Dependencies

// build.gradle.kts
dependencies {
    implementation("net.openid:appauth:0.11.1")
    implementation("androidx.security:security-crypto:1.1.0-alpha06")
}

Authorization Flow

import net.openid.appauth.*
import android.content.Intent
import android.net.Uri

class KeycloakAuthManager(private val context: Context) {

    private val serviceConfig = AuthorizationServiceConfiguration(
        Uri.parse("https://auth.example.com/realms/my-app/protocol/openid-connect/auth"),
        Uri.parse("https://auth.example.com/realms/my-app/protocol/openid-connect/token")
    )

    private val clientId = "mobile-app"
    private val redirectUri = Uri.parse("https://app.example.com/callback")

    fun buildAuthRequest(): AuthorizationRequest {
        return AuthorizationRequest.Builder(
            serviceConfig,
            clientId,
            ResponseTypeValues.CODE,
            redirectUri
        )
            .setScope("openid profile email offline_access")
            .setCodeVerifier(CodeVerifierUtil.generateRandomCodeVerifier())
            .build()
    }

    fun startAuth(activity: Activity, requestCode: Int) {
        val authService = AuthorizationService(context)
        val authRequest = buildAuthRequest()
        val authIntent = authService.getAuthorizationRequestIntent(authRequest)
        activity.startActivityForResult(authIntent, requestCode)
    }

    fun handleAuthResponse(
        intent: Intent,
        onSuccess: (TokenResponse) -> Unit,
        onError: (AuthorizationException) -> Unit
    ) {
        val response = AuthorizationResponse.fromIntent(intent)
        val exception = AuthorizationException.fromIntent(intent)

        if (response != null) {
            val authService = AuthorizationService(context)
            authService.performTokenRequest(
                response.createTokenExchangeRequest()
            ) { tokenResponse, tokenException ->
                if (tokenResponse != null) {
                    // Store tokens securely
                    SecureTokenStore(context).saveTokens(
                        accessToken = tokenResponse.accessToken ?: "",
                        refreshToken = tokenResponse.refreshToken ?: "",
                        idToken = tokenResponse.idToken ?: ""
                    )
                    onSuccess(tokenResponse)
                } else {
                    onError(tokenException ?: AuthorizationException.GeneralErrors.NETWORK_ERROR)
                }
            }
        } else if (exception != null) {
            onError(exception)
        }
    }
}

Secure Token Storage with EncryptedSharedPreferences

Android’s EncryptedSharedPreferences (part of Jetpack Security) uses the Android Keystore system for key management:

import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey

class SecureTokenStore(context: Context) {

    private val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()

    private val prefs = EncryptedSharedPreferences.create(
        context,
        "keycloak_tokens",
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )

    fun saveTokens(accessToken: String, refreshToken: String, idToken: String) {
        prefs.edit()
            .putString("access_token", accessToken)
            .putString("refresh_token", refreshToken)
            .putString("id_token", idToken)
            .putLong("token_saved_at", System.currentTimeMillis())
            .apply()
    }

    fun getAccessToken(): String? = prefs.getString("access_token", null)
    fun getRefreshToken(): String? = prefs.getString("refresh_token", null)

    fun clearTokens() {
        prefs.edit().clear().apply()
    }
}

Refresh Token Rotation

Refresh token rotation is a critical security measure for mobile apps. With rotation enabled, each time the app uses a refresh token to get a new access token, Keycloak issues a new refresh token and invalidates the old one. If an attacker steals a refresh token and uses it after the legitimate app has already rotated it, Keycloak detects the reuse and revokes the entire token family.

Enabling Rotation in Keycloak

In the Keycloak Admin Console:

  1. Go to Realm Settings > Tokens
  2. Enable Revoke Refresh Token
  3. Set Refresh Token Max Reuse to 0 (one-time use)

Implementing Token Refresh

// iOS - Token refresh with rotation handling
func refreshAccessToken(completion: @escaping (Result<String, Error>) -> Void) {
    guard let refreshToken = KeychainTokenStore.shared.load(key: "refresh_token") else {
        completion(.failure(AuthError.noRefreshToken))
        return
    }

    let tokenURL = URL(
        string: "(keycloakBaseURL)/realms/(realm)/protocol/openid-connect/token"
    )!
    var request = URLRequest(url: tokenURL)
    request.httpMethod = "POST"
    request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")

    let body = [
        "grant_type": "refresh_token",
        "client_id": clientID,
        "refresh_token": refreshToken
    ]
    request.httpBody = body.map { "($0.key)=($0.value)" }
        .joined(separator: "&").data(using: .utf8)

    URLSession.shared.dataTask(with: request) { data, response, error in
        guard let data = data,
              let httpResponse = response as? HTTPURLResponse else {
            completion(.failure(error ?? AuthError.networkError))
            return
        }

        if httpResponse.statusCode == 400 {
            // Refresh token was revoked (possible theft detected)
            // Force re-authentication
            KeychainTokenStore.shared.clearAll()
            completion(.failure(AuthError.sessionRevoked))
            return
        }

        guard let tokens = try? JSONDecoder().decode(TokenResponse.self, from: data) else {
            completion(.failure(AuthError.decodingError))
            return
        }

        // Save the NEW rotated tokens
        KeychainTokenStore.shared.save(tokens: tokens)
        completion(.success(tokens.accessToken))
    }.resume()
}

Biometric Binding

You can add biometric authentication (Face ID, Touch ID, fingerprint) as an additional layer before releasing tokens from secure storage. This does not replace Keycloak authentication but protects stored tokens if the device is unlocked by someone other than the user.

iOS Biometric Binding

import LocalAuthentication

class BiometricTokenGuard {
    func authenticateAndRetrieveToken(completion: @escaping (Result<String, Error>) -> Void) {
        let context = LAContext()
        var error: NSError?

        guard context.canEvaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics, error: &error
        ) else {
            // Biometrics unavailable, fall back to passcode or skip
            if let token = KeychainTokenStore.shared.load(key: "access_token") {
                completion(.success(token))
            } else {
                completion(.failure(AuthError.noToken))
            }
            return
        }

        context.evaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            localizedReason: "Authenticate to access your account"
        ) { success, authError in
            if success {
                if let token = KeychainTokenStore.shared.load(key: "access_token") {
                    completion(.success(token))
                } else {
                    completion(.failure(AuthError.noToken))
                }
            } else {
                completion(.failure(authError ?? AuthError.biometricFailed))
            }
        }
    }
}

For Keycloak-level biometric support using passkeys and WebAuthn, see our guide on passwordless authentication with Keycloak.

Security Checklist for Mobile OAuth

Before shipping your mobile OAuth integration, verify these items:

Requirement RFC/BCP Reference
PKCE with S256 challenge method RFC 7636
System browser (no embedded WebView) RFC 8252, Section 8.12
Universal Links / App Links for redirect URI RFC 8252, Section 7.2
Tokens stored in Keychain / Android Keystore OAuth 2.0 Security BCP
Refresh token rotation enabled OAuth 2.0 Security BCP
No client secret in the app binary RFC 8252, Section 8.5
State parameter for CSRF protection RFC 6749, Section 10.12
TLS for all token requests RFC 6749, Section 3.2

You can decode and inspect your JWT access tokens using the JWT Token Analyzer to verify that claims, scopes, and expiration times are set correctly.

Common Pitfalls and How to Avoid Them

Using WebViews: Some developers use WKWebView (iOS) or WebView (Android) for the login screen. This violates RFC 8252 because the app can inject JavaScript to steal credentials. Always use ASWebAuthenticationSession on iOS and Chrome Custom Tabs or the default browser on Android.

Storing tokens in plaintext: UserDefaults on iOS and regular SharedPreferences on Android are not encrypted. Use Keychain and EncryptedSharedPreferences respectively.

Long-lived access tokens: Mobile networks are unreliable, and long-lived tokens increase the window of compromise. Use short access tokens (5 minutes) with refresh tokens for seamless renewal.

Not handling token revocation: When your backend detects suspicious activity, it may revoke tokens via Keycloak’s Admin API. Your app must handle 401 responses gracefully by attempting a token refresh, and if that fails, redirecting to login.

Ignoring app backgrounding: When an app is backgrounded, the OS may terminate it. Ensure tokens are persisted to Keychain/Keystore before any network operation, not just kept in memory.

Keycloak-Specific Mobile Configuration

Disabling Direct Access Grants

Direct access grants (Resource Owner Password Credentials) allow the app to collect username/password directly and exchange them for tokens. This defeats the purpose of OAuth delegation. Always disable this in your Keycloak client settings.

Configuring Token Lifespans

For mobile apps specifically, consider:

  • Access token: 5 minutes (short, because refresh is cheap and seamless)
  • Refresh token: 30 days (long enough that users do not need to re-authenticate frequently)
  • SSO session idle: 30 days (match refresh token lifespan)
  • SSO session max: 90 days (absolute maximum session duration)

These values are configured in the Keycloak Admin Console under Realm Settings > Tokens and Sessions.

Offline Access Scope

If your app needs to work offline, request the offline_access scope during authorization. This issues an offline token (a refresh token that does not expire based on session idle timeout) instead of a regular refresh token. Note that offline tokens still respect the absolute session maximum.

Testing Your Mobile OAuth Flow

Testing mobile OAuth requires verifying the complete flow. Here is a testing checklist:

  1. Happy path: Login, get tokens, access a protected resource, refresh the token
  2. PKCE verification: Attempt to exchange the code without the verifier (should fail)
  3. Token rotation: Refresh the token, then attempt to use the old refresh token (should be rejected)
  4. Session revocation: Revoke the session in Keycloak Admin, then attempt to refresh (should fail gracefully)
  5. Concurrent devices: Login from two devices, revoke one session, verify the other still works
  6. Background/foreground: Background the app for longer than the access token lifespan, bring it back, verify it refreshes automatically

For monitoring authentication events and tracking login patterns across devices, Keycloak’s audit logging capabilities provide visibility into every authentication event.

Next Steps

Mobile OAuth with Keycloak requires careful attention to platform-specific security APIs and OAuth best practices, but the patterns are well-established. The key principles are: always use PKCE, always use the system browser, store tokens in the platform’s secure enclave, and enable refresh token rotation.

If you want to add multi-factor authentication to your mobile login flow, Keycloak supports TOTP, WebAuthn, and recovery codes out of the box. For apps that need single sign-on across multiple mobile apps from the same organization, shared Keycloak sessions via the system browser make this seamless.

For production deployments, Skycloak’s managed Keycloak hosting handles the infrastructure, clustering, and security hardening so you can focus on your mobile app. Check our pricing page to see what plan fits your team.

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