Keycloak + Flutter: Mobile Authentication Guide

Guilliano Molaire Guilliano Molaire Updated April 6, 2026 13 min read

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

  1. Log into http://localhost:8080/admin
  2. Create a realm called flutter-app

Create a Client

  1. Go to Clients > Create client
  2. Set:
    • Client type: OpenID Connect
    • Client ID: flutter-mobile
  3. Capability Config:
    • Client authentication: Off (public client)
    • Standard flow: Enabled
    • Direct access grants: Disabled
  4. 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)

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

  1. Go to Realm roles > create user and admin roles
  2. Go to Users > create a test user with a password
  3. Assign the user role 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

  1. Android emulator cannot reach localhost: Use 10.0.2.2 instead of localhost to reach the host machine from an Android emulator. iOS simulators can use localhost directly.

  2. Redirect URI mismatch: The redirect URI in your Keycloak client settings must exactly match what flutter_appauth sends. Check Keycloak server logs for the exact URI being used.

  3. SSL errors: For local development, you need to allow cleartext traffic on Android. For production, always use HTTPS.

  4. 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:

  1. Use HTTPS: Replace HTTP URLs with HTTPS for all Keycloak communication. Skycloak provides HTTPS-enabled Keycloak instances out of the box.

  2. Use universal links / app links: Replace custom URL schemes with HTTPS-based deep links for better security.

  3. Enable certificate pinning: Pin your Keycloak server’s certificate to prevent MITM attacks.

  4. Set appropriate token lifespans: Access tokens should be short (5-15 minutes), refresh tokens longer but with an absolute maximum.

  5. Handle offline scenarios: Decide how your app behaves when it cannot reach Keycloak (cached data, error messages, etc.).

  6. Enable MFA: Use Keycloak’s multi-factor authentication for sensitive operations. Keycloak supports TOTP and WebAuthn, both of which work well on mobile.

  7. Monitor authentication events: Use Skycloak’s insights for real-time monitoring of mobile authentication patterns.

  8. Configure session policies: Set up session management policies appropriate for mobile (longer sessions with idle timeouts).

Next Steps

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.

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