Keycloak + React Native (Expo): Mobile Auth Guide

Guilliano Molaire Guilliano Molaire Updated June 5, 2026 10 min read

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

Step 1: Set Up a Keycloak Client

Mobile apps are public clients — they cannot securely store a client secret. Configure Keycloak accordingly:

  1. Go to Clients > Create client
  2. Set Client ID to expo-mobile-app
  3. Set Client type to OpenID Connect
  4. Set Client authentication to Off (public client)
  5. Under Valid redirect URIs, add:
    • exp://127.0.0.1:8081/--/auth/callback (Expo Go development)
    • myapp://auth/callback (production custom scheme)
  6. Under Valid post logout redirect URIs, add the same URIs
  7. 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 PKCE
  • expo-crypto — generates the PKCE code verifier and challenge
  • expo-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 browser
  • expo-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 NSFaceIDUsageDescription key in app.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

  1. Never store tokens in AsyncStorage — it is unencrypted plain text on both platforms
  2. Always use PKCE — public clients cannot store secrets, so PKCE proves the token request came from the same client that initiated the auth flow
  3. Request offline_access scope — without it, you will not get a refresh token, and users will need to re-authenticate whenever the access token expires
  4. Implement session timeout — clear tokens and require re-authentication after a period of inactivity
  5. Pin SSL certificates in production to prevent MITM attacks (use expo-certificate-pinning or 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

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.

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