OAuth for Mobile Apps: Best Practices with Keycloak
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:
- Go to Realm Settings > Tokens
- Enable Revoke Refresh Token
- 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:
- Happy path: Login, get tokens, access a protected resource, refresh the token
- PKCE verification: Attempt to exchange the code without the verifier (should fail)
- Token rotation: Refresh the token, then attempt to use the old refresh token (should be rejected)
- Session revocation: Revoke the session in Keycloak Admin, then attempt to refresh (should fail gracefully)
- Concurrent devices: Login from two devices, revoke one session, verify the other still works
- 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.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.