Keycloak + React Native (Expo): Mobile Auth Guide
Last updated: March 2026
Mobile authentication has constraints that web apps do not face. You cannot store secrets in a mobile binary (it can be decompiled), you cannot rely on HTTP-only cookies (there is no browser), and you need to handle app backgrounding, token refresh, and biometric unlock. React Native with Expo gives you a cross-platform framework, and Keycloak gives you enterprise-grade identity management — but connecting the two requires careful attention to security.
This guide walks through a complete Keycloak + React Native (Expo) integration using expo-auth-session for the PKCE flow, expo-secure-store for token persistence, deep linking, silent token refresh, and optional biometric unlock.
Prerequisites
- Node.js 18+ and npm 10+
- Expo CLI:
npx expo(included withcreate-expo-app) - A running Keycloak instance (version 22+). Use our Docker Compose Generator or try managed Keycloak hosting.
- Expo Go app on your phone or an iOS/Android simulator
Step 1: Set Up a Keycloak Client
Mobile apps are public clients — they cannot securely store a client secret. Configure Keycloak accordingly:
- Go to Clients > Create client
- Set Client ID to
expo-mobile-app - Set Client type to
OpenID Connect - Set Client authentication to Off (public client)
- Under Valid redirect URIs, add:
exp://127.0.0.1:8081/--/auth/callback(Expo Go development)myapp://auth/callback(production custom scheme)
- Under Valid post logout redirect URIs, add the same URIs
- Under Web origins, add
*for development (restrict in production)
Under the Advanced tab:
- Set Proof Key for Code Exchange Code Challenge Method to
S256 - Ensure Backchannel logout is disabled (mobile apps use front-channel)
Create a Test User
Under Users, create a test user with a password. Optionally enable multi-factor authentication to test MFA flows on mobile.
Step 2: Create the Expo Project
npx create-expo-app keycloak-expo-app
cd keycloak-expo-app
Install the required dependencies:
npx expo install expo-auth-session expo-crypto expo-secure-store expo-web-browser expo-local-authentication expo-linking
Each package serves a purpose:
expo-auth-session— handles the OAuth 2.0 authorization code flow with PKCEexpo-crypto— generates the PKCE code verifier and challengeexpo-secure-store— stores tokens in the device keychain (iOS) or encrypted shared preferences (Android)expo-web-browser— opens the Keycloak login page in an in-app browserexpo-local-authentication— biometric authentication (Face ID, fingerprint)expo-linking— deep link handling for auth callbacks
Step 3: Configure Deep Linking
Update your app.json to register the custom URL scheme:
{
"expo": {
"name": "keycloak-expo-app",
"slug": "keycloak-expo-app",
"scheme": "myapp",
"ios": {
"bundleIdentifier": "com.yourcompany.keycloakexpo",
"infoPlist": {
"NSFaceIDUsageDescription": "Use Face ID to unlock the app"
}
},
"android": {
"package": "com.yourcompany.keycloakexpo",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "myapp",
"host": "auth",
"pathPrefix": "/callback"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
},
"plugins": [
"expo-secure-store",
[
"expo-local-authentication",
{
"faceIDPermission": "Allow $(PRODUCT_NAME) to use Face ID for authentication"
}
]
]
}
}
Step 4: Keycloak Configuration Module
Create a configuration module that works across Expo Go (development) and standalone builds (production):
// src/config/keycloak.ts
import { makeRedirectUri } from "expo-auth-session";
import Constants from "expo-constants";
const KEYCLOAK_URL =
process.env.EXPO_PUBLIC_KEYCLOAK_URL ||
"http://localhost:8080";
const KEYCLOAK_REALM =
process.env.EXPO_PUBLIC_KEYCLOAK_REALM || "myrealm";
const KEYCLOAK_CLIENT_ID =
process.env.EXPO_PUBLIC_KEYCLOAK_CLIENT_ID ||
"expo-mobile-app";
export const keycloakConfig = {
baseUrl: KEYCLOAK_URL,
realm: KEYCLOAK_REALM,
clientId: KEYCLOAK_CLIENT_ID,
realmUrl: `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}`,
// OIDC endpoints
discovery: {
authorizationEndpoint: `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/auth`,
tokenEndpoint: `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`,
revocationEndpoint: `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/revoke`,
endSessionEndpoint: `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/logout`,
userInfoEndpoint: `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/userinfo`,
},
// Redirect URI changes based on environment
redirectUri: makeRedirectUri({
scheme: "myapp",
path: "auth/callback",
}),
scopes: ["openid", "profile", "email", "offline_access"],
};
The offline_access scope is important — it requests a refresh token, which is essential for keeping users logged in without re-prompting.
Step 5: Secure Token Storage
Tokens must never be stored in plain AsyncStorage. Use expo-secure-store to leverage the device keychain:
// src/services/token-storage.ts
import * as SecureStore from "expo-secure-store";
const TOKEN_KEYS = {
ACCESS_TOKEN: "kc_access_token",
REFRESH_TOKEN: "kc_refresh_token",
ID_TOKEN: "kc_id_token",
EXPIRES_AT: "kc_expires_at",
} as const;
export interface StoredTokens {
accessToken: string;
refreshToken?: string;
idToken?: string;
expiresAt: number;
}
export async function saveTokens(
tokens: StoredTokens
): Promise<void> {
await Promise.all([
SecureStore.setItemAsync(
TOKEN_KEYS.ACCESS_TOKEN,
tokens.accessToken
),
SecureStore.setItemAsync(
TOKEN_KEYS.EXPIRES_AT,
tokens.expiresAt.toString()
),
tokens.refreshToken
? SecureStore.setItemAsync(
TOKEN_KEYS.REFRESH_TOKEN,
tokens.refreshToken
)
: Promise.resolve(),
tokens.idToken
? SecureStore.setItemAsync(
TOKEN_KEYS.ID_TOKEN,
tokens.idToken
)
: Promise.resolve(),
]);
}
export async function getTokens(): Promise<StoredTokens | null> {
const [accessToken, refreshToken, idToken, expiresAt] =
await Promise.all([
SecureStore.getItemAsync(TOKEN_KEYS.ACCESS_TOKEN),
SecureStore.getItemAsync(TOKEN_KEYS.REFRESH_TOKEN),
SecureStore.getItemAsync(TOKEN_KEYS.ID_TOKEN),
SecureStore.getItemAsync(TOKEN_KEYS.EXPIRES_AT),
]);
if (!accessToken || !expiresAt) return null;
return {
accessToken,
refreshToken: refreshToken || undefined,
idToken: idToken || undefined,
expiresAt: parseInt(expiresAt, 10),
};
}
export async function clearTokens(): Promise<void> {
await Promise.all(
Object.values(TOKEN_KEYS).map((key) =>
SecureStore.deleteItemAsync(key)
)
);
}
export function isTokenExpired(expiresAt: number): boolean {
// Add 30 second buffer for clock skew
return Date.now() >= (expiresAt - 30) * 1000;
}
Step 6: Authentication Hook
This is the core of the integration. The useAuth hook manages the entire authentication lifecycle:
// src/hooks/useAuth.ts
import { useState, useEffect, useCallback } from "react";
import * as AuthSession from "expo-auth-session";
import * as WebBrowser from "expo-web-browser";
import {
saveTokens,
getTokens,
clearTokens,
isTokenExpired,
StoredTokens,
} from "../services/token-storage";
import { keycloakConfig } from "../config/keycloak";
// Required for proper redirect handling on web
WebBrowser.maybeCompleteAuthSession();
interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
user: UserInfo | null;
error: string | null;
}
interface UserInfo {
sub: string;
name?: string;
email?: string;
preferred_username?: string;
email_verified?: boolean;
realm_access?: { roles: string[] };
}
export function useAuth() {
const [state, setState] = useState<AuthState>({
isAuthenticated: false,
isLoading: true,
user: null,
error: null,
});
// Create the auth request with PKCE
const [request, response, promptAsync] =
AuthSession.useAuthRequest(
{
clientId: keycloakConfig.clientId,
redirectUri: keycloakConfig.redirectUri,
scopes: keycloakConfig.scopes,
usePKCE: true,
responseType: AuthSession.ResponseType.Code,
},
keycloakConfig.discovery
);
// Handle the auth response
useEffect(() => {
if (response?.type === "success") {
const { code } = response.params;
exchangeCodeForTokens(code);
} else if (response?.type === "error") {
setState((prev) => ({
...prev,
isLoading: false,
error:
response.error?.message || "Authentication failed",
}));
}
}, [response]);
// Check for existing tokens on mount
useEffect(() => {
checkExistingSession();
}, []);
async function checkExistingSession() {
try {
const tokens = await getTokens();
if (!tokens) {
setState((prev) => ({ ...prev, isLoading: false }));
return;
}
if (isTokenExpired(tokens.expiresAt)) {
if (tokens.refreshToken) {
await refreshAccessToken(tokens.refreshToken);
} else {
await clearTokens();
setState((prev) => ({ ...prev, isLoading: false }));
}
} else {
const user = await fetchUserInfo(tokens.accessToken);
setState({
isAuthenticated: true,
isLoading: false,
user,
error: null,
});
}
} catch {
await clearTokens();
setState((prev) => ({ ...prev, isLoading: false }));
}
}
async function exchangeCodeForTokens(code: string) {
try {
const tokenResponse =
await AuthSession.exchangeCodeAsync(
{
clientId: keycloakConfig.clientId,
code,
redirectUri: keycloakConfig.redirectUri,
extraParams: {
code_verifier: request?.codeVerifier || "",
},
},
keycloakConfig.discovery
);
const tokens: StoredTokens = {
accessToken: tokenResponse.accessToken,
refreshToken:
tokenResponse.refreshToken || undefined,
idToken: tokenResponse.idToken || undefined,
expiresAt: Math.floor(
(tokenResponse.issuedAt +
(tokenResponse.expiresIn || 300))
),
};
await saveTokens(tokens);
const user = await fetchUserInfo(tokens.accessToken);
setState({
isAuthenticated: true,
isLoading: false,
user,
error: null,
});
} catch (err: any) {
setState((prev) => ({
...prev,
isLoading: false,
error: err.message || "Token exchange failed",
}));
}
}
async function refreshAccessToken(
refreshToken: string
): Promise<void> {
try {
const tokenResponse =
await AuthSession.refreshAsync(
{
clientId: keycloakConfig.clientId,
refreshToken,
},
keycloakConfig.discovery
);
const tokens: StoredTokens = {
accessToken: tokenResponse.accessToken,
refreshToken:
tokenResponse.refreshToken || refreshToken,
idToken: tokenResponse.idToken || undefined,
expiresAt: Math.floor(
(tokenResponse.issuedAt +
(tokenResponse.expiresIn || 300))
),
};
await saveTokens(tokens);
const user = await fetchUserInfo(tokens.accessToken);
setState({
isAuthenticated: true,
isLoading: false,
user,
error: null,
});
} catch {
// Refresh failed --- clear tokens and require re-login
await clearTokens();
setState({
isAuthenticated: false,
isLoading: false,
user: null,
error: null,
});
}
}
async function fetchUserInfo(
accessToken: string
): Promise<UserInfo> {
const res = await fetch(
keycloakConfig.discovery.userInfoEndpoint,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!res.ok) {
throw new Error("Failed to fetch user info");
}
return res.json();
}
const login = useCallback(async () => {
setState((prev) => ({
...prev,
isLoading: true,
error: null,
}));
await promptAsync();
}, [promptAsync]);
const logout = useCallback(async () => {
try {
const tokens = await getTokens();
if (tokens?.idToken) {
// Redirect to Keycloak logout endpoint
await WebBrowser.openAuthSessionAsync(
`${keycloakConfig.discovery.endSessionEndpoint}?` +
`id_token_hint=${tokens.idToken}&` +
`post_logout_redirect_uri=${encodeURIComponent(keycloakConfig.redirectUri)}`,
keycloakConfig.redirectUri
);
}
} finally {
await clearTokens();
setState({
isAuthenticated: false,
isLoading: false,
user: null,
error: null,
});
}
}, []);
return {
...state,
login,
logout,
request,
};
}
For a deeper understanding of token lifecycle management, see JWT token lifecycle management.
Step 7: Biometric Unlock
After initial authentication, you can let users unlock the app with Face ID or fingerprint instead of re-authenticating through Keycloak:
// src/hooks/useBiometricAuth.ts
import { useState, useEffect } from "react";
import * as LocalAuthentication from "expo-local-authentication";
export function useBiometricAuth() {
const [isBiometricAvailable, setIsBiometricAvailable] =
useState(false);
const [biometricType, setBiometricType] = useState<
string | null
>(null);
useEffect(() => {
checkBiometricAvailability();
}, []);
async function checkBiometricAvailability() {
const compatible =
await LocalAuthentication.hasHardwareAsync();
const enrolled =
await LocalAuthentication.isEnrolledAsync();
setIsBiometricAvailable(compatible && enrolled);
if (compatible && enrolled) {
const types =
await LocalAuthentication.supportedAuthenticationTypesAsync();
if (
types.includes(
LocalAuthentication.AuthenticationType
.FACIAL_RECOGNITION
)
) {
setBiometricType("Face ID");
} else if (
types.includes(
LocalAuthentication.AuthenticationType.FINGERPRINT
)
) {
setBiometricType("Fingerprint");
}
}
}
async function authenticate(): Promise<boolean> {
if (!isBiometricAvailable) return false;
const result = await LocalAuthentication.authenticateAsync(
{
promptMessage: "Authenticate to continue",
fallbackLabel: "Use password",
cancelLabel: "Cancel",
disableDeviceFallback: false,
}
);
return result.success;
}
return {
isBiometricAvailable,
biometricType,
authenticate,
};
}
Integrate it with the main app flow:
// In your app entry point
import { useBiometricAuth } from "./hooks/useBiometricAuth";
import { useAuth } from "./hooks/useAuth";
function App() {
const auth = useAuth();
const biometric = useBiometricAuth();
const [isUnlocked, setIsUnlocked] = useState(false);
useEffect(() => {
if (
auth.isAuthenticated &&
!isUnlocked &&
biometric.isBiometricAvailable
) {
biometric.authenticate().then((success) => {
setIsUnlocked(success);
if (!success) {
// Fall back to Keycloak login
auth.logout();
}
});
}
}, [auth.isAuthenticated]);
// ... render based on auth + biometric state
}
Step 8: Protected API Calls
Create a wrapper for authenticated API requests that automatically handles token refresh:
// src/services/api-client.ts
import {
getTokens,
isTokenExpired,
saveTokens,
clearTokens,
} from "./token-storage";
import { keycloakConfig } from "../config/keycloak";
import * as AuthSession from "expo-auth-session";
async function getValidAccessToken(): Promise<
string | null
> {
const tokens = await getTokens();
if (!tokens) return null;
if (!isTokenExpired(tokens.expiresAt)) {
return tokens.accessToken;
}
// Attempt refresh
if (tokens.refreshToken) {
try {
const tokenResponse =
await AuthSession.refreshAsync(
{
clientId: keycloakConfig.clientId,
refreshToken: tokens.refreshToken,
},
keycloakConfig.discovery
);
const newTokens = {
accessToken: tokenResponse.accessToken,
refreshToken:
tokenResponse.refreshToken ||
tokens.refreshToken,
idToken: tokenResponse.idToken || undefined,
expiresAt: Math.floor(
tokenResponse.issuedAt +
(tokenResponse.expiresIn || 300)
),
};
await saveTokens(newTokens);
return newTokens.accessToken;
} catch {
await clearTokens();
return null;
}
}
return null;
}
export async function apiRequest<T>(
url: string,
options: RequestInit = {}
): Promise<T> {
const accessToken = await getValidAccessToken();
if (!accessToken) {
throw new Error("Not authenticated");
}
const response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (response.status === 401) {
// Token was rejected --- clear and require re-login
await clearTokens();
throw new Error("Session expired");
}
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
Step 9: Main App Screen
Bring everything together in a simple app screen:
// App.tsx
import { StatusBar } from "expo-status-bar";
import {
StyleSheet,
Text,
View,
TouchableOpacity,
ActivityIndicator,
ScrollView,
} from "react-native";
import { useAuth } from "./src/hooks/useAuth";
export default function App() {
const { isAuthenticated, isLoading, user, error, login, logout } =
useAuth();
if (isLoading) {
return (
<View style={styles.container}>
<ActivityIndicator size="large" color="#3b82f6" />
<Text style={styles.loadingText}>Loading...</Text>
</View>
);
}
if (!isAuthenticated) {
return (
<View style={styles.container}>
<Text style={styles.title}>Welcome</Text>
<Text style={styles.subtitle}>
Sign in to continue
</Text>
{error && (
<Text style={styles.error}>{error}</Text>
)}
<TouchableOpacity
style={styles.button}
onPress={login}
>
<Text style={styles.buttonText}>
Sign in with Keycloak
</Text>
</TouchableOpacity>
<StatusBar style="auto" />
</View>
);
}
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>
Hello, {user?.preferred_username || user?.name}
</Text>
<View style={styles.card}>
<Text style={styles.label}>Email</Text>
<Text style={styles.value}>{user?.email}</Text>
<Text style={styles.label}>User ID</Text>
<Text style={styles.value}>{user?.sub}</Text>
<Text style={styles.label}>Roles</Text>
<Text style={styles.value}>
{user?.realm_access?.roles?.join(", ") || "None"}
</Text>
</View>
<TouchableOpacity
style={[styles.button, styles.logoutButton]}
onPress={logout}
>
<Text style={styles.buttonText}>Sign out</Text>
</TouchableOpacity>
<StatusBar style="auto" />
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: 20,
backgroundColor: "#f8fafc",
},
title: {
fontSize: 28,
fontWeight: "bold",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: "#64748b",
marginBottom: 32,
},
button: {
backgroundColor: "#3b82f6",
paddingHorizontal: 32,
paddingVertical: 14,
borderRadius: 8,
marginTop: 16,
},
logoutButton: {
backgroundColor: "#ef4444",
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
card: {
backgroundColor: "#fff",
padding: 20,
borderRadius: 12,
width: "100%",
shadowColor: "#000",
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
marginVertical: 20,
},
label: {
fontSize: 12,
color: "#64748b",
marginTop: 12,
textTransform: "uppercase",
},
value: {
fontSize: 16,
marginTop: 4,
},
loadingText: {
marginTop: 16,
color: "#64748b",
},
error: {
color: "#ef4444",
marginBottom: 16,
},
});
Platform-Specific Notes
iOS
- The in-app browser (ASWebAuthenticationSession) presents a system dialog asking “app wants to use keycloak.example.com to sign in.” This is expected behavior.
- Face ID requires the
NSFaceIDUsageDescriptionkey inapp.json(configured in Step 3). - Keychain items persist across app reinstalls unless explicitly cleared.
Android
- Custom tabs are used for the auth browser. Ensure your Keycloak instance has a valid SSL certificate in production.
- Android Keystore backs
expo-secure-store. On devices without hardware-backed keystore, software-backed encryption is used. - Deep link intent filters need the custom scheme registered in
app.json.
Expo Go vs Development Builds
Expo Go uses the exp:// scheme, which changes the redirect URI. For production, use a development build with your custom scheme. The makeRedirectUri function from expo-auth-session handles this automatically.
Security Considerations
- Never store tokens in AsyncStorage — it is unencrypted plain text on both platforms
- Always use PKCE — public clients cannot store secrets, so PKCE proves the token request came from the same client that initiated the auth flow
- Request
offline_accessscope — without it, you will not get a refresh token, and users will need to re-authenticate whenever the access token expires - Implement session timeout — clear tokens and require re-authentication after a period of inactivity
- Pin SSL certificates in production to prevent MITM attacks (use
expo-certificate-pinningor a custom native module)
For more on securing authentication flows, see our guide on session management and Keycloak security best practices.
Testing
Inspect the tokens your app receives by copying them into our JWT Token Analyzer. Verify the claims, roles, and expiration match your Keycloak client configuration.
For testing SAML-based identity providers that your Keycloak instance brokers, our SAML Decoder helps debug assertion payloads.
Further Reading
- Keycloak Securing Applications Guide
- Expo AuthSession Documentation
- OAuth 2.0 for Native Apps (RFC 8252)
- React web app integration with Keycloak
- Next.js App Router authentication with Keycloak
- Passwordless authentication with passkeys — WebAuthn on mobile
Wrapping Up
Mobile authentication with Keycloak and Expo is achievable once you understand the constraints: public clients with PKCE, secure token storage in the device keychain, and proper token refresh handling. The expo-auth-session library handles the heavy lifting of the OAuth flow, while expo-secure-store ensures tokens are protected at rest.
If you want to skip running and maintaining your own Keycloak infrastructure, Skycloak provides managed Keycloak with built-in security and a guaranteed SLA. Check our pricing page 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.