After facing the limitation for an auth package web support, I was looking for auth solutions to have a platform based smooth authentication experience. So the major authentication methods I wanted in the app I was building was,
I wanted to have all these methods available to me on all the targeted platforms (android, iOS and web) for our react-native app. I knew a lot of auth libraries for web, and a lot of libraries for mobile only, and I've been following Supabase's launch week, for quite a while.
when the decision to move on with web build support for the app came in, I dived right in supabase-auth and ended up with a working product that supported native social logins on respective native platforms and browser based logins on web, for our react native application.
Supabase auth is more than an auth service, it provides you dashboard to manage users and implement Row Level Security for it's users. Auth hooks are an added feature to implement custom behavior on user creation. Check more here:
https://supabase.com/docs/guides/auth/auth-hooks
How to setup auth in an Expo app
We are using Tamagui(ui lib) in the app, so make sure to install it.
yarn add @tamagui
Let me just share the code snippets and a walkthrough tutorial of how you can achieve the same set of authentication, which to me sounds ideal.
Install the following packages to setup supabase client, storage and secure store for cross-platform expo app.
yarn add @supabase/supabase-js expo-crypto expo-secure-store react-native-mmkv
Create a storage.ts and supabase.ts under /utils folder
import { createClient } from '@supabase/supabase-js';
import { AppState } from 'react-native';
import 'react-native-url-polyfill/auto';
import * as SessionStorage from './storage';
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
storage: SessionStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
});
export { type AuthError, type Session } from '@supabase/supabase-js';
AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'active') {
supabase.auth.startAutoRefresh();
} else {
supabase.auth.stopAutoRefresh();
}
});
import * as Crypto from 'expo-crypto';
import * as SecureStore from 'expo-secure-store';
import { MMKV } from 'react-native-mmkv';
import { isWeb } from 'tamagui';
const fetchOrGenerateEncryptionKey = (): string => {
const encryptionKey = SecureStore.getItem('session-encryption-key');
if (encryptionKey) {
return encryptionKey;
} else {
const uuid = Crypto.randomUUID();
SecureStore.setItem('session-encryption-key', uuid);
return uuid;
}
};
export const storage = new MMKV({
id: 'session',
encryptionKey: !isWeb ? fetchOrGenerateEncryptionKey() : undefined,
});
storage.set('workaround', true);
export async function getItem(key: string): Promise<string | null> {
try {
return storage.getString(key) ?? null;
} catch {
console.warn(`Failed to get key "${key}" from secure storage`);
return null;
}
}
export async function setItem(key: string, value: string): Promise<void> {
try {
storage.set(key, value);
} catch {
console.warn(`Failed to set key "${key}" in secure storage`);
}
}
export async function removeItem(key: string): Promise<void> {
try {
storage.delete(key);
} catch {
console.warn(`Failed to remove key "${key}" from secure storage`);
}
}
Then create an Auth Component that has conditional, native first segregation of auth providers, via supabase.
Install the following packages yarn add expo-auth-session expo-web-browser
import { makeRedirectUri } from 'expo-auth-session';
import * as QueryParams from 'expo-auth-session/build/QueryParams';
import * as WebBrowser from 'expo-web-browser';
import { useState } from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { useMMKVObject, useMMKVString } from 'react-native-mmkv';
import { captureSentryException } from 'sentry_telemetry/sentryLogger';
import { Stack, XStack, YStack } from 'tamagui';
import MyInput from '~/components/basic/TextInput';
import { colors } from '~/constants/color';
import { fontSize } from '~/constants/fontSize';
import { supabase } from '~/utils/supabase';
import DottedLine from '../DottedLine';
import Text from '../basic/Text';
import { Button } from '../button';
import Icon from '../icon/Icon';
import WebBasedGoogleLogin from './WebBasedGoogleLogin';
WebBrowser.maybeCompleteAuthSession();
const redirectTo = makeRedirectUri({});
const createSessionFromUrl = async (url: string) => {
const { params, errorCode } = QueryParams.getQueryParams(url);
if (errorCode) throw new Error(errorCode);
const { access_token, refresh_token } = params;
if (!access_token) return;
const { data, error } = await supabase.auth.setSession({
access_token,
refresh_token,
});
if (error) throw error;
return data.session;
};
type AuthProps = {
isAndroid: boolean;
isWeb: boolean;
isIos: boolean;
};
export default function Auth({ isAndroid, isIos, isWeb }: AuthProps) {
const toast = useToastController();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [loginOrSignup, setLoginOrSignup] = useState<'Login' | 'SignUp'>('Login');
const [accessToken, setAccessToken] = useMMKVString('access_token');
const [refreshToken, setRefreshToken] = useMMKVString('refresh_token');
console.log('accessToken value:', accessToken);
const [mmkvUser, setMMKVUser] = useMMKVObject<Employee>('mmkv_user');
const [upsertUser] = useUpsertUserMutation();
async function signInWithEmail() {
setLoading(true);
try {
const {
error,
data: { user, session },
} = await supabase.auth.signInWithPassword({
email: email,
password: password,
});
if (error) {
throw new Error(error.message);
}
if (session?.access_token) {
upsertUser({
fetchPolicy: 'network-only',
refetchQueries: [{ query: GetLoggedInUserDocument }],
onCompleted(data) {
setMMKVUser(data?.register);
setAccessToken(session.access_token);
},
onError(error) {
console.log('error', error);
captureSentryException('Error in creating new user', error as any);
},
});
}
} catch (error) {
} finally {
setLoading(false);
}
}
const performOAuthGoogle = async () => {
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: redirectTo,
skipBrowserRedirect: true,
queryParams: {
access_type: 'offline',
prompt: 'consent',
},
scopes: '',
},
});
console.log('OauthGoolge', data);
if (error) throw error;
console.log('OauthGoolg', data?.url, redirectTo);
const res = await WebBrowser.openAuthSessionAsync(data?.url ?? '', redirectTo);
if (res.type === 'success') {
const { url } = res;
console.log(url);
const credential = await createSessionFromUrl(url);
console.log('after successful token generation', credential);
setAccessToken(credential?.access_token);
setRefreshToken(credential?.refresh_token);
}
};
const performOAuthApple = async () => {
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'apple',
options: {
redirectTo,
skipBrowserRedirect: true,
},
});
console.log(data?.url);
if (error) throw error;
console.log(error);
const res = await WebBrowser.openAuthSessionAsync(data?.url ?? '', redirectTo);
if (res.type === 'success') {
const { url } = res;
console.log(url);
const credential = await createSessionFromUrl(url);
console.log(credential?.access_token);
setAccessToken(credential?.access_token);
setRefreshToken(credential?.refresh_token);
}
};
async function signUpWithEmail() {
setLoading(true);
try {
const {
data: { session },
error,
} = await supabase.auth.signUp({
email: email,
password: password,
});
if (error) {
throw new Error(error.message);
}
console.log(session?.user);
if (!session) {
}
} catch (error) {
} finally {
setLoading(false);
}
}
return (
<Stack w={'80%'} alignSelf="center">
<YStack w={'90%'} gap={10} alignSelf="center">
<MyInput
label="Email"
onChangeText={(text) => setEmail(text)}
value={email}
placeholder="Email"
autoCapitalize={'none'}
/>
<MyInput
label="Password"
onChangeText={(text) => setPassword(text)}
value={password}
secureTextEntry={true}
placeholder="Password"
autoCapitalize={'none'}
/>
{loginOrSignup === 'Login' ? (
<View style={[styles.verticallySpaced, styles.mt20]}>
<Button
backgroundColor={!email || !password ? '$background' : '$primary'}
disabled={!email || !password}
onPress={() => signInWithEmail()}>
{loading ? (
<ActivityIndicator color={colors.primary} />
) : (
<Text variant="semi-bold" color={!email || !password ? 'black' : 'white'}>
Log in
</Text>
)}
</Button>
</View>
) : (
<View style={styles.verticallySpaced}>
<Button
backgroundColor={!email || !password ? '$background' : '$primary'}
disabled={!email || !password}
onPress={() => signUpWithEmail()}>
{loading ? (
<ActivityIndicator color={colors.primary} />
) : (
<Text variant="semi-bold" color={!email || !password ? 'black' : 'white'}>
Register
</Text>
)}
</Button>
</View>
)}
</YStack>
{isIos && !isAndroid ? (
<WebBasedGoogleLogin />
) : (
<View style={styles.verticallySpaced}>
<Button
w={'70%'}
onPress={performOAuthGoogle}
alignSelf="center"
style={styles.googleBtn}>
<Text color={'white'} variant="bold">
Sign in With Google
</Text>
</Button>
</View>
)}
{isWeb && (
<View style={styles.verticallySpaced}>
<Button w={'70%'} alignSelf="center" onPress={performOAuthApple} style={styles.appleBtn}>
<Text color={'white'} variant="semi-bold">
Sign in With Apple
</Text>
</Button>
</View>
)}
<DottedLine />
<XStack gap={4} alignSelf="center">
<Text
onPress={() => setLoginOrSignup(loginOrSignup === 'Login' ? 'SignUp' : 'Login')}
style={{
textAlign: 'center',
color: colors.primary,
fontSize: fontSize.lg,
fontWeight: '500',
}}>
{loginOrSignup === 'Login' ? 'Sign Up' : 'Log in'}
</Text>
</XStack>
</Stack>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
},
emailInput: {
marginBottom: 10,
},
verticallySpaced: {
paddingTop: 4,
paddingBottom: 4,
alignSelf: 'stretch',
},
mt20: {
marginTop: 20,
},
appleBtn: {
backgroundColor: colors.black,
borderRadius: 100,
height: 50,
},
googleBtn: {
backgroundColor: colors.orange,
borderRadius: 100,
height: 50,
},
});
Now create respective, NativeAppleSignIn.tsx and NativeGoogleSignIn.tsx. Install the required packages by running yarn add expo-apple-authentication @react-native-google-signin/google-signin
import {
GoogleSignin,
GoogleSigninButton,
statusCodes,
} from '@react-native-google-signin/google-signin';
import 'core-js/stable/atob';
import {
Employee,
GetLoggedInUserDocument,
useRegisterUserMutation,
} from 'generated/hooks_and_more';
import jwtDecode from 'jwt-decode';
import { Platform } from 'react-native';
import { useMMKVObject, useMMKVString } from 'react-native-mmkv';
import { captureSentryException } from 'sentry_telemetry/sentryLogger';
import { supabase } from '~/utils/supabase';
export default function GoogleSignIn() {
const [accessToken, setAccessToken] = useMMKVString('access_token');
const [refreshToken, setRefreshToken] = useMMKVString('refresh_token');
const [mmkvUser, setMMKVUser] = useMMKVObject<Employee>('mmkv_user');
const [registerUser, { data: userResponse, loading: registerLoading }] =
useRegisterUserMutation();
GoogleSignin.configure({
iosClientId: process.env.EXPO_PUBLIC_IOS_GOOGLE_CLIENT_ID,
webClientId: process.env.EXPO_PUBLIC_WEB_GOOGLE_CLIENT_ID,
offlineAccess: true,
scopes: ['profile', 'email'],
});
async function onPress(){
try {
await GoogleSignin.hasPlayServices();
const userInfo = await GoogleSignin.signIn();
const decodedToken = jwtDecode(userInfo.idToken);
const nonceFromToken = decodedToken.nonce;
console.log(nonceFromToken);
if (userInfo.idToken) {
const { data, error } = await supabase.auth.signInWithIdToken({
provider: 'google',
token: userInfo.idToken,
});
console.log('google native', error, data);
setAccessToken(data?.session?.access_token || '');
setRefreshToken(data?.session?.refresh_token || '');
registerUser({
refetchQueries: [{ query: GetLoggedInUserDocument }],
onCompleted(data, clientOptions) {
setMMKVUser(data?.register);
},
onError(error, clientOptions) {
console.log('error', error);
captureSentryException(
'Error in creating new user[Native Google SignIn]',
error as any
);
},
});
} else {
throw new Error('no ID token present!');
}
} catch (error: any) {
if (error.code === statusCodes.SIGN_IN_CANCELLED) {
console.log('user cancelled the sign in', error);
} else if (error.code === statusCodes.IN_PROGRESS) {
console.log('user cancelled the login flow', error);
} else if (error.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) {
console.log('play services not available or outdated', error);
} else {
console.log('some other error happened', error);
}
}
}}
if (Platform.OS === 'android') {
return (
<GoogleSigninButton
size={GoogleSigninButton.Size.Standard}
color={GoogleSigninButton.Color.Dark}
onPress={onPress}
/>
);
}
return null;
}
import * as AppleAuthentication from 'expo-apple-authentication';
import {
Employee,
GetLoggedInUserDocument,
useRegisterUserMutation,
} from 'generated/hooks_and_more';
import { Platform } from 'react-native';
import { useMMKVObject, useMMKVString } from 'react-native-mmkv';
import { captureSentryException } from 'sentry_telemetry/sentryLogger';
import { supabase } from '~/utils/supabase';
export default function AppleSignIn() {
const [accessToken, setAccessToken] = useMMKVString('access_token');
const [refreshToken, setRefreshToken] = useMMKVString('refresh_token');
const [mmkvUser, setMMKVUser] = useMMKVObject<Employee>('mmkv_user');
const [registerUser, { data: userResponse, loading: registerLoading }] =
useRegisterUserMutation();
const handleAppleSignIn = async () => {
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
console.log(credential);
if (credential.identityToken) {
const {
error,
data: { user, session },
} = await supabase.auth.signInWithIdToken({
provider: 'apple',
token: credential.identityToken,
});
console.log(JSON.stringify({ error, user }, null, 2));
if (!error) {
setAccessToken(session?.access_token || '');
setRefreshToken(session?.refresh_token || '');
registerUser({
refetchQueries: [{ query: GetLoggedInUserDocument }],
onCompleted(data, clientOptions) {
setMMKVUser(data?.register);
console.log('on registered in Native Apple Login', accessToken);
},
onError(error, clientOptions) {
console.log('error', error);
captureSentryException('Error in creating new user', error as any);
},
});
}
} else {
throw new Error('No identityToken.');
}
} catch (e) {
if (e.code === 'ERR_REQUEST_CANCELED') {
} else {
}
}
};
if (Platform.OS === 'ios') {
return (
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN}
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={5}
style={{ width: 200, height: 64 }}
onPress={handleAppleSignIn}
/>
);
}
return <>{}</>;
}
There was an issue that I face with native Google sign in on iOS for quite long and chose to drop it for a while and use an alternative that supports cross-platform auth.
For that, create a WebBasedGoogleLogin.tsx file and use it in the Auth component universally. Add the packages by running
import * as Google from 'expo-auth-session/providers/google';
import * as WebBrowser from 'expo-web-browser';
import {
Employee,
GetLoggedInUserDocument,
useRegisterUserMutation,
} from 'generated/hooks_and_more';
import { useEffect, useState } from 'react';
import { StyleSheet } from 'react-native';
import { useMMKVObject, useMMKVString } from 'react-native-mmkv';
import { captureSentryException } from 'sentry_telemetry/sentryLogger';
import { setRefreshToken } from '~/hooks/storage/token';
import { supabase } from '~/utils/supabase';
import Text from '../basic/Text';
import { Button } from '../button';
WebBrowser.maybeCompleteAuthSession();
export default function WebBasedGoogleLogin() {
const [userInfo, setUserInfo] = useState();
const [mmkvUser, setMMKVUser] = useMMKVObject('mmkv_user');
const [accessToken, setAccessToken] = useMMKVString('access_token');
const [registerUser, { data: userResponse, loading: registerLoading }] =
useRegisterUserMutation();
const [request, response, promptAsync] = Google.useAuthRequest({
androidClientId: process.env.EXPO_PUBLIC_ANDROID_GOOGLE_CLIENT_ID,
webClientId: process.env.EXPO_PUBLIC_WEB_GOOGLE_CLIENT_ID,
iosClientId: process.env.EXPO_PUBLIC_IOS_GOOGLE_CLIENT_ID,
});
useEffect(() => {
handleSignInWithGoogle();
}, [response]);
async function handleSignInWithGoogle() {
if (!mmkvUser) {
if (response?.type === 'success') {
const { data, error } = await supabase.auth.signInWithIdToken({
provider: 'google',
token: response?.authentication?.idToken,
});
setAccessToken(data?.session?.access_token);
setRefreshToken(data?.session?.refresh_token);
setMMKVUser(data?.session?.user);
}
} else {
setMMKVUser('');
}
}
const getUserInfo = async (token) => {
if (!token) return;
try {
const res = await fetch('https://www.googleapis.com/userinfo/v2/me', {
headers: {
Authorization: `Bearer ${token}`,
},
});
const user = await res.json();
console.log(user);
registerUser({
refetchQueries: [{ query: GetLoggedInUserDocument }],
onCompleted(data, clientOptions) {
setMMKVUser(data?.register);
setUserInfo(data?.register);
},
onError(error, clientOptions) {
console.log('error', error);
captureSentryException('Error in creating new user', error as any);
},
});
} catch (error) {}
};
return (
<>
<Text>{JSON.stringify(userInfo)}</Text>
<Button w={'70%'} alignSelf="center" onPress={() => promptAsync()} style={styles.googleBtn}>
<Text variant="semi-bold" color={'white'}>
Sign in with Google
</Text>
</Button>
</>
);
}
const styles = StyleSheet.create({
googleBtn: {
backgroundColor: '#4285F4',
borderRadius: 4,
paddingVertical: 12,
paddingHorizontal: 16,
elevation: 3,
},
});
All of these components only work, if I pass the accessToken in the headers of my fetcher function, and to do secure API calls, add this to your fetcher headers,
const JWT_TOKEN = (await supabase.auth.getSession()).data.session?.access_token;
Make sure to add the following environment variables
//.env
EXPO_PUBLIC_SUPABASE_URL=
EXPO_PUBLIC_SUPABASE_ANON_KEY=
EXPO_PUBLIC_IOS_GOOGLE_CLIENT_ID=
EXPO_PUBLIC_WEB_GOOGLE_CLIENT_ID=
EXPO_PUBLIC_ANDROID_GOOGLE_CLIENT_ID=
EXPO_PUBLIC_SUPABASE_REDIRECT_URI=
I've followed the guides available on various platforms and github issues, to be able to work on this. Supabase-Auth is a marvelous library, it has every major auth provider of the internet, Supabase auth client is written in go, please do give it a ⭐ on github.
Thanks for reading this tutorial, If you found it useful. Let me know. If you have any doubts, do reach out to me on twitter @Agrit Tiwari
Cheers!