expo-auth-session & OAuth
Authorization Code with PKCE is the standard OAuth pattern for Expo apps. This page covers redirect URI setup, expo-auth-session wiring, and provider-specific quirks for Google and Apple on SDK 57.
Search across all documentation pages
Authorization Code with PKCE is the standard OAuth pattern for Expo apps. This page covers redirect URI setup, expo-auth-session wiring, and provider-specific quirks for Google and Apple on SDK 57.
npx expo install expo-auth-session expo-crypto expo-web-browserexpo-web-browser opens the system browser (or ASWebAuthenticationSession on iOS) - prefer it over embedded WebViews for OAuth.
Register a custom URL scheme in app.config.ts:
// app.config.ts
export default {
expo: {
name: "MyApp",
slug: "my-app",
scheme: "myapp",
ios: { bundleIdentifier: "com.example.myapp" },
android: { package: "com.example.myapp" },
},
};Tooling: These examples target Expo SDK 57 (
expo~57.0.4), React Native 0.86.0, and React 19.2.3.
PKCE (Proof Key for Code Exchange) prevents authorization code interception on public mobile clients.
import * as Crypto from "expo-crypto";
function base64UrlEncode(bytes: Uint8Array) {
const base64 = btoa(String.fromCharCode(...bytes));
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export async function createPkcePair() {
const verifierBytes = await Crypto.getRandomBytesAsync(32);
const codeVerifier = base64UrlEncode(verifierBytes);
const digest = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
codeVerifier,
{ encoding: Crypto.CryptoEncoding.BASE64 },
);
const codeChallenge = digest
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return { codeVerifier, codeChallenge };
}expo-auth-session generates PKCE pairs automatically when usePKCE: true - manual generation is for custom backendsresponse_type=token) - access tokens in redirect URLs leak via logs and referrer headersRelated: Mobile Auth Basics - normalizing OAuth tokens into your
Sessiontype
Redirect URIs must match exactly what you register with each OAuth provider.
import { makeRedirectUri } from "expo-auth-session";
import * as AuthSession from "expo-auth-session";
const redirectUri = makeRedirectUri({
scheme: "myapp",
path: "oauth",
});
// Examples by environment:
// Dev client / standalone: myapp://oauth
// Expo Go (proxy): https://auth.expo.io/@owner/slug (legacy - avoid for production)
const discovery = {
authorizationEndpoint: "https://accounts.example.com/oauth/authorize",
tokenEndpoint: "https://accounts.example.com/oauth/token",
};
const [request, response, promptAsync] = AuthSession.useAuthRequest(
{
clientId: process.env.EXPO_PUBLIC_OAUTH_CLIENT_ID!,
redirectUri,
scopes: ["openid", "profile", "email"],
usePKCE: true,
responseType: AuthSession.ResponseType.Code,
},
discovery,
);makeRedirectUri derives the correct URI from your scheme, native/development build, and Expo configredirect_uri_mismatchmyapp://) for production - not exp:// or Expo Go proxy URLsredirectUri once in development to copy into Google Cloud Console and Apple Developer portalimport { useEffect } from "react";
import { Button, Text, View } from "react-native";
import * as AuthSession from "expo-auth-session";
import { makeRedirectUri } from "expo-auth-session";
import * as WebBrowser from "expo-web-browser";
import * as SecureStore from "expo-secure-store";
WebBrowser.maybeCompleteAuthSession();
const redirectUri = makeRedirectUri({ scheme: "myapp", path: "oauth" });
const discovery: AuthSession.DiscoveryDocument = {
authorizationEndpoint: `${process.env.EXPO_PUBLIC_API_URL}/oauth/authorize`,
tokenEndpoint: `${process.env.EXPO_PUBLIC_API_URL}/oauth/token`,
};
export function OAuthSignInButton() {
const [request, response, promptAsync] = AuthSession.useAuthRequest(
{
clientId: process.env.EXPO_PUBLIC_OAUTH_CLIENT_ID!,
redirectUri,
scopes: ["openid", "profile", "email"],
usePKCE: true,
},
discovery,
);
useEffect(() => {
if (response?.type !== "success") return;
const { code } = response.params;
exchangeCodeOnBackend(code, request?.codeVerifier);
}, [response]);
return (
<View>
<Button
title="Sign in"
disabled={!request}
onPress={() => promptAsync({ preferEphemeralSession: true })}
/>
</View>
);
}
async function exchangeCodeOnBackend(code: string, codeVerifier?: string) {
const res = await fetch(`${process.env.EXPO_PUBLIC_API_URL}/auth/oauth/callback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, codeVerifier, redirectUri }),
});
if (!res.ok) throw new Error("OAuth exchange failed");
const { refreshToken, accessToken, expiresAt, userId } = await res.json();
await SecureStore.setItemAsync("auth.refresh_token", refreshToken, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
// Hydrate AuthProvider session state with { accessToken, refreshToken, expiresAt, userId }
}WebBrowser.maybeCompleteAuthSession() dismisses the browser tab when the redirect fires - call once at module scopepreferEphemeralSession: true on iOS avoids sharing cookies with Safari - good for shared devicesRelated: ../expo-rules/security-rules-for-mobile/security-rules-for-mobile.md - never ship OAuth client secrets in the bundle
Google requires separate OAuth client IDs per platform.
Google Cloud Console → APIs & Services → Credentials
┌─────────────────┬──────────────────────────────────────────────┐
│ Client type │ Used for │
├─────────────────┼──────────────────────────────────────────────┤
│ iOS │ Bundle ID: com.example.myapp │
│ Android │ Package + SHA-1 signing cert fingerprint │
│ Web │ Backend code exchange (client secret) │
└─────────────────┴──────────────────────────────────────────────┘// Use the iOS or Android client ID in the app - NOT the web client secret
const GOOGLE_CLIENT_ID = Platform.select({
ios: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID,
android: process.env.EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID,
default: process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID,
});expo-auth-session, your app scheme handles the redirectCommon errors:
| Error | Fix |
|---|---|
redirect_uri_mismatch | Copy exact makeRedirectUri() output into Google console |
invalid_client on Android | Add correct SHA-1 for the build profile you are testing |
access_denied | OAuth consent screen in Testing mode - add test users |
Apple Sign In is required when you offer third-party sign-in on iOS for apps in certain categories.
{
"expo": {
"ios": {
"usesAppleSignIn": true,
"bundleIdentifier": "com.example.myapp"
},
"plugins": ["expo-apple-authentication"]
}
}npx expo install expo-apple-authenticationimport * as AppleAuthentication from "expo-apple-authentication";
import { Platform } from "react-native";
export function AppleSignInButton() {
if (Platform.OS !== "ios") return null;
return (
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN}
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={8}
style={{ width: "100%", height: 44 }}
onPress={async () => {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
await sendAppleCredentialToBackend(credential);
}}
/>
);
}@privaterelay.appleid.com addressesexpo-apple-authentication uses native Sign in with Apple - distinct from generic OAuth browser flowFor Auth0, Okta, or Keycloak, use built-in discovery:
import * as AuthSession from "expo-auth-session";
const discovery = await AuthSession.fetchDiscoveryAsync(
"https://your-tenant.auth0.com",
);
const [request, response, promptAsync] = AuthSession.useAuthRequest(
{
clientId: process.env.EXPO_PUBLIC_AUTH0_CLIENT_ID!,
redirectUri: makeRedirectUri({ scheme: "myapp" }),
scopes: ["openid", "profile", "offline_access"],
usePKCE: true,
extraParams: { audience: "https://api.example.com" },
},
discovery,
);offline_access scope is required on many IdPs to receive a refresh tokenaudience (Auth0) ensures access tokens are minted for your API - not just the IdP userinfo endpointauthorizationEndpoint and tokenEndpoint in discovery JSONBefore shipping OAuth:
□ redirectUri logged and registered for prod scheme (myapp://)
□ Google Android SHA-1 matches EAS production keystore
□ Apple Sign In entitlement present in provisioning profile
□ Backend validates code_verifier (PKCE)
□ Refresh tokens stored in SecureStore - not AsyncStorage
□ Tested on physical device with development build - not only Expo Go
□ OAuth tested after app kill + relaunch (cold start refresh)expo-dev-client) with your real bundle ID before OAuth integration testingRelated: Auth Session Lifecycle - refresh after OAuth on app resume
expo-auth-session + expo-web-browser (system browser / ASWebAuthenticationSession).eas credentials and add the correct fingerprint to Google Cloud Console.myapp://) are sufficient for most apps.https://app.example.com/oauth) add complexity but avoid scheme collision - optional for enterprise.response.type === "cancel" or "dismiss" - show nothing or a soft message; do not treat as an error worth logging to Sentry.Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026