Keycloak + Flutter: Mobile Authentication Guide
Last updated: March 2026
Mobile authentication has different requirements than web authentication. You cannot rely on browser cookies, you need to store tokens securely on the device, and the OAuth redirect flow works differently on mobile platforms. Keycloak handles these challenges well through its support for OAuth 2.0 with PKCE (Proof Key for Code Exchange), which is the recommended flow for mobile applications.
This guide walks through integrating Keycloak with a Flutter application using flutter_appauth for the OAuth flow, flutter_secure_storage for token persistence, and platform-specific configuration for deep linking. By the end, you will have a working mobile app with login, logout, secure token storage, automatic token refresh, and optional biometric authentication for accessing stored credentials.
Prerequisites
- Flutter SDK 3.16+ with Dart 3.2+
- Android Studio or Xcode (for platform-specific setup)
- A running Keycloak instance (version 22+). Use the Skycloak Docker Compose Generator for local development or a managed instance from Skycloak.
Start a local Keycloak:
docker run -p 8080:8080
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin
quay.io/keycloak/keycloak:26.0 start-dev
Keycloak Client Configuration
Mobile applications are public clients (they cannot store a client secret securely). Configure Keycloak accordingly.
Create a Realm
- Log into
http://localhost:8080/admin - Create a realm called
flutter-app
Create a Client
- Go to Clients > Create client
- Set:
- Client type: OpenID Connect
- Client ID:
flutter-mobile
- Capability Config:
- Client authentication: Off (public client)
- Standard flow: Enabled
- Direct access grants: Disabled
- Login Settings:
- Valid redirect URIs:
io.skycloak.flutterapp:/callback - Valid post logout redirect URIs:
io.skycloak.flutterapp:/callback - Web origins:
+(allow all origins for mobile)
- Valid redirect URIs:
The redirect URI uses a custom scheme (io.skycloak.flutterapp://callback). This is how the mobile OS routes the OAuth callback back to your app after the user authenticates in the system browser.
Create Roles and Test User
- Go to Realm roles > create
userandadminroles - Go to Users > create a test user with a password
- Assign the
userrole under Role mappings
Flutter Project Setup
Create a new Flutter project:
flutter create flutter_keycloak
cd flutter_keycloak
Update pubspec.yaml with required dependencies:
name: flutter_keycloak
description: Keycloak authentication example
version: 1.0.0
environment:
sdk: '>=3.2.0 <4.0.0'
flutter: '>=3.16.0'
dependencies:
flutter:
sdk: flutter
flutter_appauth: ^7.0.1
flutter_secure_storage: ^9.2.3
http: ^1.2.2
jwt_decoder: ^2.0.1
local_auth: ^2.3.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
Install dependencies:
flutter pub get
Platform-Specific Configuration
Android Setup
Edit android/app/build.gradle to set the minimum SDK version and add the redirect scheme:
android {
defaultConfig {
minSdkVersion 23
targetSdkVersion 34
manifestPlaceholders += [
'appAuthRedirectScheme': 'io.skycloak.flutterapp'
]
}
}
For biometric authentication, add to android/app/src/main/AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="flutter_keycloak"
android:usesCleartextTraffic="true">
<!-- usesCleartextTraffic only for local dev with HTTP -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>
iOS Setup
Edit ios/Runner/Info.plist to add the custom URL scheme:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>io.skycloak.flutterapp</string>
</array>
<key>CFBundleURLName</key>
<string>io.skycloak.flutterapp</string>
</dict>
</array>
For biometric authentication on iOS, add to Info.plist:
<key>NSFaceIDUsageDescription</key>
<string>Authenticate to access your account</string>
Authentication Service
Create the core authentication service at lib/services/auth_service.dart:
import 'dart:convert';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
class AuthService {
static final AuthService _instance = AuthService._internal();
factory AuthService() => _instance;
AuthService._internal();
final FlutterAppAuth _appAuth = const FlutterAppAuth();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
);
// Configuration
static const String _clientId = 'flutter-mobile';
static const String _redirectUri = 'io.skycloak.flutterapp:/callback';
static const String _issuer = 'http://10.0.2.2:8080/realms/flutter-app';
// Use 10.0.2.2 for Android emulator, localhost for iOS simulator
static const String _discoveryUrl =
'$_issuer/.well-known/openid-configuration';
// Storage keys
static const String _accessTokenKey = 'access_token';
static const String _refreshTokenKey = 'refresh_token';
static const String _idTokenKey = 'id_token';
// Cached state
String? _accessToken;
String? _refreshToken;
String? _idToken;
Map<String, dynamic>? _tokenPayload;
bool get isAuthenticated => _accessToken != null && !isTokenExpired;
bool get isTokenExpired {
if (_accessToken == null) return true;
try {
return JwtDecoder.isExpired(_accessToken!);
} catch (_) {
return true;
}
}
Map<String, dynamic>? get userInfo => _tokenPayload;
String? get userName =>
_tokenPayload?['preferred_username'] as String?;
String? get email => _tokenPayload?['email'] as String?;
List<String> get roles {
final realmAccess =
_tokenPayload?['realm_access'] as Map<String, dynamic>?;
final rolesList = realmAccess?['roles'] as List<dynamic>?;
return rolesList?.map((r) => r.toString()).toList() ?? [];
}
bool hasRole(String role) => roles.contains(role);
/// Initialize the service by loading stored tokens
Future<bool> init() async {
_accessToken = await _secureStorage.read(key: _accessTokenKey);
_refreshToken = await _secureStorage.read(key: _refreshTokenKey);
_idToken = await _secureStorage.read(key: _idTokenKey);
if (_accessToken != null) {
_decodeToken();
if (isTokenExpired) {
// Try to refresh
final refreshed = await refreshTokens();
return refreshed;
}
return true;
}
return false;
}
/// Perform the login flow
Future<bool> login() async {
try {
final result = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
_clientId,
_redirectUri,
discoveryUrl: _discoveryUrl,
scopes: ['openid', 'profile', 'email', 'offline_access'],
promptValues: ['login'],
),
);
if (result != null) {
await _handleTokenResponse(result);
return true;
}
return false;
} catch (e) {
print('Login error: $e');
return false;
}
}
/// Refresh access token using the refresh token
Future<bool> refreshTokens() async {
if (_refreshToken == null) return false;
try {
final result = await _appAuth.token(
TokenRequest(
_clientId,
_redirectUri,
discoveryUrl: _discoveryUrl,
refreshToken: _refreshToken,
scopes: ['openid', 'profile', 'email', 'offline_access'],
),
);
if (result != null) {
await _handleTokenResponse(result);
return true;
}
return false;
} catch (e) {
print('Token refresh error: $e');
// Refresh token may be expired, clear everything
await logout();
return false;
}
}
/// Get a valid access token, refreshing if necessary
Future<String?> getValidToken() async {
if (_accessToken == null) return null;
if (isTokenExpired) {
final refreshed = await refreshTokens();
if (!refreshed) return null;
}
return _accessToken;
}
/// Log out the user
Future<void> logout() async {
// End the Keycloak session
if (_idToken != null) {
try {
await _appAuth.endSession(
EndSessionRequest(
idTokenHint: _idToken,
postLogoutRedirectUrl: _redirectUri,
discoveryUrl: _discoveryUrl,
),
);
} catch (e) {
print('End session error: $e');
}
}
// Clear stored tokens
await _secureStorage.delete(key: _accessTokenKey);
await _secureStorage.delete(key: _refreshTokenKey);
await _secureStorage.delete(key: _idTokenKey);
_accessToken = null;
_refreshToken = null;
_idToken = null;
_tokenPayload = null;
}
/// Handle token response from AppAuth
Future<void> _handleTokenResponse(TokenResponse response) async {
_accessToken = response.accessToken;
_refreshToken = response.refreshToken;
_idToken = response.idToken;
// Store tokens securely
if (_accessToken != null) {
await _secureStorage.write(
key: _accessTokenKey, value: _accessToken);
}
if (_refreshToken != null) {
await _secureStorage.write(
key: _refreshTokenKey, value: _refreshToken);
}
if (_idToken != null) {
await _secureStorage.write(key: _idTokenKey, value: _idToken);
}
_decodeToken();
}
void _decodeToken() {
if (_accessToken != null) {
try {
_tokenPayload = JwtDecoder.decode(_accessToken!);
} catch (e) {
print('Token decode error: $e');
_tokenPayload = null;
}
}
}
}
The service uses flutter_secure_storage which stores tokens in:
- Android: EncryptedSharedPreferences (AES-256 encrypted)
- iOS: Keychain Services (hardware-backed encryption)
This ensures tokens are stored securely on the device. The PKCE flow (handled automatically by flutter_appauth) prevents authorization code interception attacks, which is critical for mobile apps. You can use the JWT Token Analyzer to inspect the tokens Keycloak issues during development.
Biometric Authentication
Add biometric authentication to protect access to stored tokens. Create lib/services/biometric_service.dart:
import 'package:local_auth/local_auth.dart';
class BiometricService {
static final BiometricService _instance = BiometricService._internal();
factory BiometricService() => _instance;
BiometricService._internal();
final LocalAuthentication _localAuth = LocalAuthentication();
/// Check if biometric authentication is available
Future<bool> isAvailable() async {
try {
final canCheck = await _localAuth.canCheckBiometrics;
final isDeviceSupported = await _localAuth.isDeviceSupported();
return canCheck && isDeviceSupported;
} catch (_) {
return false;
}
}
/// Get available biometric types
Future<List<BiometricType>> getAvailableBiometrics() async {
try {
return await _localAuth.getAvailableBiometrics();
} catch (_) {
return [];
}
}
/// Authenticate with biometrics
Future<bool> authenticate({
String reason = 'Authenticate to access your account',
}) async {
try {
return await _localAuth.authenticate(
localizedReason: reason,
options: const AuthenticationOptions(
stickyAuth: true,
biometricOnly: false, // Allow PIN/pattern as fallback
),
);
} catch (e) {
print('Biometric auth error: $e');
return false;
}
}
}
HTTP Client with Auth
Create an authenticated HTTP client at lib/services/api_service.dart:
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'auth_service.dart';
class ApiService {
static final ApiService _instance = ApiService._internal();
factory ApiService() => _instance;
ApiService._internal();
final AuthService _authService = AuthService();
final String _baseUrl = 'http://10.0.2.2:3000/api'; // Your API base URL
/// Make an authenticated GET request
Future<Map<String, dynamic>?> get(String path) async {
final token = await _authService.getValidToken();
if (token == null) {
throw Exception('Not authenticated');
}
final response = await http.get(
Uri.parse('$_baseUrl$path'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
);
if (response.statusCode == 401) {
// Token was rejected, try refreshing
final refreshed = await _authService.refreshTokens();
if (refreshed) {
return get(path); // Retry with new token
}
throw Exception('Authentication expired');
}
if (response.statusCode == 403) {
throw Exception('Insufficient permissions');
}
if (response.statusCode >= 200 && response.statusCode < 300) {
return jsonDecode(response.body) as Map<String, dynamic>;
}
throw Exception(
'API error: ${response.statusCode} ${response.body}');
}
/// Make an authenticated POST request
Future<Map<String, dynamic>?> post(
String path,
Map<String, dynamic> body,
) async {
final token = await _authService.getValidToken();
if (token == null) {
throw Exception('Not authenticated');
}
final response = await http.post(
Uri.parse('$_baseUrl$path'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
body: jsonEncode(body),
);
if (response.statusCode == 401) {
final refreshed = await _authService.refreshTokens();
if (refreshed) {
return post(path, body);
}
throw Exception('Authentication expired');
}
if (response.statusCode >= 200 && response.statusCode < 300) {
return jsonDecode(response.body) as Map<String, dynamic>;
}
throw Exception(
'API error: ${response.statusCode} ${response.body}');
}
}
If you encounter 403 errors from your API, our 403 Forbidden troubleshooting guide covers all the common causes including missing roles, audience mismatches, and scope issues.
Building the UI
Main App
Update lib/main.dart:
import 'package:flutter/material.dart';
import 'services/auth_service.dart';
import 'screens/login_screen.dart';
import 'screens/home_screen.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter + Keycloak',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF3b82f6),
brightness: Brightness.light,
),
useMaterial3: true,
),
home: const AuthGate(),
);
}
}
class AuthGate extends StatefulWidget {
const AuthGate({super.key});
@override
State<AuthGate> createState() => _AuthGateState();
}
class _AuthGateState extends State<AuthGate> {
final AuthService _authService = AuthService();
bool _isLoading = true;
bool _isAuthenticated = false;
@override
void initState() {
super.initState();
_checkAuth();
}
Future<void> _checkAuth() async {
final authenticated = await _authService.init();
setState(() {
_isAuthenticated = authenticated;
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (_isAuthenticated) {
return HomeScreen(
onLogout: () async {
await _authService.logout();
setState(() => _isAuthenticated = false);
},
);
}
return LoginScreen(
onLogin: () async {
final success = await _authService.login();
if (success) {
setState(() => _isAuthenticated = true);
}
},
);
}
}
Login Screen
Create lib/screens/login_screen.dart:
import 'package:flutter/material.dart';
import '../services/biometric_service.dart';
import '../services/auth_service.dart';
class LoginScreen extends StatefulWidget {
final VoidCallback onLogin;
const LoginScreen({super.key, required this.onLogin});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final BiometricService _biometricService = BiometricService();
final AuthService _authService = AuthService();
bool _biometricsAvailable = false;
bool _hasStoredSession = false;
bool _isLoading = false;
@override
void initState() {
super.initState();
_checkBiometrics();
}
Future<void> _checkBiometrics() async {
final available = await _biometricService.isAvailable();
// Check if there is a stored refresh token
final hasSession = await _authService.init();
setState(() {
_biometricsAvailable = available;
_hasStoredSession = hasSession;
});
}
Future<void> _loginWithBiometrics() async {
setState(() => _isLoading = true);
final authenticated = await _biometricService.authenticate(
reason: 'Verify your identity to sign in',
);
if (authenticated) {
// Biometrics passed, use stored tokens
final tokenValid = await _authService.init();
if (tokenValid) {
widget.onLogin();
} else {
// Stored tokens expired, need full login
widget.onLogin();
}
}
setState(() => _isLoading = false);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(
Icons.lock_outline,
size: 80,
color: Color(0xFF3b82f6),
),
const SizedBox(height: 24),
Text(
'Welcome',
style: Theme.of(context).textTheme.headlineLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Sign in with your Keycloak account',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
FilledButton(
onPressed: _isLoading ? null : widget.onLogin,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: const Color(0xFF3b82f6),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Sign in with Keycloak',
style: TextStyle(fontSize: 16),
),
),
if (_biometricsAvailable && _hasStoredSession) ...[
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _isLoading ? null : _loginWithBiometrics,
icon: const Icon(Icons.fingerprint),
label: const Text('Use Biometrics'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
],
],
),
),
),
);
}
}
Home Screen
Create lib/screens/home_screen.dart:
import 'package:flutter/material.dart';
import '../services/auth_service.dart';
class HomeScreen extends StatelessWidget {
final VoidCallback onLogout;
HomeScreen({super.key, required this.onLogout});
final AuthService _authService = AuthService();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Dashboard'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: onLogout,
tooltip: 'Sign out',
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Welcome, ${_authService.userName ?? "User"}',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Email: ${_authService.email ?? "N/A"}',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
Text(
'Roles',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: _authService.roles
.map(
(role) => Chip(
label: Text(role),
backgroundColor:
const Color(0xFF3b82f6).withValues(
alpha: 0.1,
),
),
)
.toList(),
),
],
),
),
),
const SizedBox(height: 16),
if (_authService.hasRole('admin'))
Card(
color: const Color(0xFF1e40af),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
const Icon(Icons.admin_panel_settings,
color: Colors.white),
const SizedBox(width: 12),
Text(
'Admin Panel Access',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Colors.white),
),
],
),
),
),
],
),
),
);
}
}
Deep Linking Configuration
For production apps, you should use universal links (iOS) and app links (Android) instead of custom URL schemes. These provide better security and a smoother user experience.
Android App Links
Add to android/app/src/main/AndroidManifest.xml inside the <activity> tag:
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="auth.yourapp.com"
android:pathPrefix="/callback" />
</intent-filter>
iOS Universal Links
Create or update ios/Runner/Runner.entitlements:
<?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>com.apple.developer.associated-domains</key>
<array>
<string>applinks:auth.yourapp.com</string>
</array>
</dict>
</plist>
When using universal links, update the redirect URI in both your Keycloak client settings and the AuthService configuration to use the HTTPS URL instead of the custom scheme.
Token Lifecycle Management
Mobile apps need to handle token lifecycle carefully since users may leave the app open for extended periods.
Add this to your AuthService:
/// Check and refresh token before making API calls
/// Call this in your app's lifecycle callbacks
Future<void> ensureValidToken() async {
if (_accessToken == null) return;
// Refresh if token expires within 60 seconds
try {
final payload = JwtDecoder.decode(_accessToken!);
final exp = payload['exp'] as int;
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
if (exp - now < 60) {
await refreshTokens();
}
} catch (_) {
await refreshTokens();
}
}
Call ensureValidToken() in your app’s didChangeAppLifecycleState:
class _AuthGateState extends State<AuthGate> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_checkAuth();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_authService.ensureValidToken();
}
}
// ... rest of the class
}
Debugging Tips
Common Issues
-
Android emulator cannot reach localhost: Use
10.0.2.2instead oflocalhostto reach the host machine from an Android emulator. iOS simulators can uselocalhostdirectly. -
Redirect URI mismatch: The redirect URI in your Keycloak client settings must exactly match what
flutter_appauthsends. Check Keycloak server logs for the exact URI being used. -
SSL errors: For local development, you need to allow cleartext traffic on Android. For production, always use HTTPS.
-
Token not containing roles: Verify your Keycloak client scope includes the realm roles. Check that role mappers are configured to include roles in the access token.
Inspecting Tokens
During development, you can log the decoded token to see what Keycloak is returning:
if (_accessToken != null) {
final payload = JwtDecoder.decode(_accessToken!);
print('Token payload: $payload');
}
For a more detailed inspection, copy the raw token and paste it into the JWT Token Analyzer.
Keycloak Event Logging
Enable event logging in Keycloak (Realm Settings > Events) to see all authentication attempts, including mobile logins. Keycloak’s audit logs capture event type, client ID, IP address, and user agent, which helps differentiate mobile from web authentication attempts.
Production Checklist
Before releasing your app:
-
Use HTTPS: Replace HTTP URLs with HTTPS for all Keycloak communication. Skycloak provides HTTPS-enabled Keycloak instances out of the box.
-
Use universal links / app links: Replace custom URL schemes with HTTPS-based deep links for better security.
-
Enable certificate pinning: Pin your Keycloak server’s certificate to prevent MITM attacks.
-
Set appropriate token lifespans: Access tokens should be short (5-15 minutes), refresh tokens longer but with an absolute maximum.
-
Handle offline scenarios: Decide how your app behaves when it cannot reach Keycloak (cached data, error messages, etc.).
-
Enable MFA: Use Keycloak’s multi-factor authentication for sensitive operations. Keycloak supports TOTP and WebAuthn, both of which work well on mobile.
-
Monitor authentication events: Use Skycloak’s insights for real-time monitoring of mobile authentication patterns.
-
Configure session policies: Set up session management policies appropriate for mobile (longer sessions with idle timeouts).
Next Steps
- Add identity provider federation for social logins (Google, Apple Sign-In)
- Implement SCIM for automated user provisioning
- Customize the Keycloak login page with your brand theming for a consistent mobile experience
- Read the Keycloak securing applications guide for advanced configuration
Try Skycloak
Building mobile apps is complex enough without managing identity infrastructure. Skycloak provides fully managed Keycloak with HTTPS, high availability, and automatic updates. See our pricing to find the right plan for your mobile app.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.