Handling Supabase Password Reset in React Native
Mo Khazali9 min read
When working on a project that used Supabase for its authentication, we faced many issues in the password reset flow. Our app was using React Native on the front end, and Supabase on the backend. The official documentation had a section describing how to implement the password reset flow using the JavaScript SDK, however this wasn’t working correctly in our app.
As described on the Supabase JS SDK docs, there are three main steps to password reset:
- Requesting a password reset for a given email.
- Automatically logging the user into the application.
- Prompting them to update their password.
The example given in the docs for React are as follows:
/**
* Step 1: Send the user an email to get a password reset token.
* This email contains a link which sends the user back to your application.
*/
const { data, error } = await supabase.auth.resetPasswordForEmail(
"user@email.com"
);
/**
* Step 2: Once the user is redirected back to your application,
* ask the user to reset their password.
*/
useEffect(() => {
supabase.auth.onAuthStateChange(async (event, session) => {
if (event == "PASSWORD_RECOVERY") {
const newPassword = prompt(
"What would you like your new password to be?"
);
const { data, error } = await supabase.auth.updateUser({
password: newPassword,
});
if (data) alert("Password updated successfully!");
if (error) alert("There was an error updating your password.");
}
});
}, []);
Replicating this in React Native, we found that there was never a "PASSWORD_RECOVERY"
event being emitted in the onAuthStateChange
function. This meant that the user was being navigated back to the app, but they were still logged out and unable to change their password.
We dug into Github issues to see if anyone was facing similar problems, and deep within a thread we found a bunch of lost RN developers (like ourselves) who were struggling with password reset.
We ultimately decided to go down this route. There were a few gotchas that we had to deal with along the way.
Let’s break down the steps to get this working:
- Requesting Password Reset
- Token-based Login
- Handling Deep Linking
- Authenticating the User as Part of the Deeplink
- Defining the Root Navigator
Requesting Password Reset
import * as Linking from "expo-linking";
const resetPassword = async (email: string) => {
const resetPasswordURL = Linking.createURL("/ResetPassword");
const { data, error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: resetPasswordURL,
});
return { data, error };
};
We’ve defined a function (that gets triggered from the app whenever the user requests a password reset) that takes in the inputted email and calls the resetPasswordForEmail
function in the JS SDK. We use the createURL
function from expo-linking
to generate a link for the redirect URL. This means that you don’t need to worry about managing URLs for local, staging, and production environments - the URL will be generated for that environment when the password reset request is sent.
Token-based Login
We’ll need to handle login of the user once they’re redirected back to the app. The redirect URL includes an access_token
and a refresh_token
, which can be used to login a user rather than the traditional username/password login. We define an AuthContext
with a loginWithToken
function that authenticates the users using tokens and sets the state with the User
information coming from the SDK’s response once authenticated successfully.
import { User } from "@supabase/supabase-js";
import { createContext, FC, ReactNode, useContext, useState } from "react";
type Tokens = {
access_token: string;
refresh_token: string;
};
type UserContextProps = {
user: User | null;
loginWithToken: (
credentials: Tokens,
options?: CallbackOptions
) => Promise<void>;
};
const AuthContext = createContext<UserContextProps | null>(null);
type AuthProviderProps = {
children: ReactNode;
};
export const AuthProvider: FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const loginWithToken = async ({ access_token, refresh_token }: Tokens) => {
const signIn = async () => {
await supabase.auth.setSession({
access_token,
refresh_token,
});
return await supabase.auth.refreshSession();
};
const {
data: { user: supabaseUser },
} = await signIn();
setUser(supabaseUser);
};
return (
<AuthContext.Provider
value={{
user,
loginWithToken,
}}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("context must be used within an AuthProvider");
}
return context;
};
We will use our loginWithToken
method in the next steps to login the user whenever we’re linked back to the app from Supabase’s password reset email.
Handling Deep Linking
Now that we have the functionality setup to request a password request, and login with tokens, we’ll set up linking for our navigation. This is where a lot of the gotchas comes up.
Gotcha #1: Shimming Buffers
Before that, we’ll need to shim Buffer
, so we’ll run npm install buffer
(or yarn add buffer
) to add it to our dependencies. Buffers are used by React Navigation to parse query parameters.
global.Buffer = global.Buffer || require("buffer").Buffer;
After installing the dependency, we set the the buffer at the global scope and shim it so Typescript doesn’t throw an error when we access query parameters.
Gotcha #2: Supabase’s Unconventional Query Parameters
Typical URLs will include query parameters at the end with a ?
. For example, you could have https://test.com/random?page=1&user=tester
.
When analysing the Supabase redirect URLs from the reset password, we found that it was structured differently, with a #
rather than a ?
denoting where the query parameters start. This doesn’t work with React Navigation, where it’s expecting the ?
symbol.
As a result, we need to create a function that parses the Supabase redirect URL and replaces the #
with a ?
before it’s handled for deep links by our navigator.
We define the following function:
const parseSupabaseUrl = (url: string) => {
let parsedUrl = url;
if (url.includes("#")) {
parsedUrl = url.replace("#", "?");
}
return parsedUrl;
};
Authenticating the User as Part of the Deeplink
Lastly, we’ll need to manually handle incoming links and login the user if tokens are present in the query parameters of a URL. React Navigation has a subscribe
prop inside the linking
object. The function lets you handle incoming links instead of the default deep link handling, and trigger side effects.
const subscribe = (listener: (url: string) => void) => {
const onReceiveURL = ({ url }: { url: string }) => {
const transformedUrl = parseSupabaseUrl(url);
const parsedUrl = Linking.parse(transformedUrl);
const access_token = parsedUrl.queryParams?.access_token;
const refresh_token = parsedUrl.queryParams?.refresh_token;
if (typeof access_token === "string" && typeof refresh_token === "string") {
void loginWithToken({ access_token, refresh_token });
}
// Call the listener to let React Navigation handle the URL
listener(transformedUrl);
};
const subscription = Linking.addEventListener("url", onReceiveURL);
// Cleanup
return () => {
subscription.remove();
};
};
In the function, we call the parseSupabaseUrl
we’ve defined to fix the URL structure from Supabase. Afterwards, we use React Navigation to parse the URLs and get the access_token
and refresh_token
query parameters). If they exist, then we call the loginWithToken
function we defined earlier. Lastly, we’ll pass back the URL to React Navigation so that it handles the deep linking.
Defining the Root Navigator
Let’s tie all these components together and define our root navigator.
Navigator
We’ll need to define a ResetPassword
screen inside our RootNavigator. Make sure this screen isn’t being added to the stack conditionally as this can mess with deep linking in ReactNavigation. Assuming you have an AuthenticatedStack
and UnauthenticatedStack
, your RootStack navigator would look something like this:
<Root.Navigator>
{isLoggedIn ? (
<Root.Screen
name={RootStackRoutes.AuthenticatedStack}
component={AuthenticatedStack}
options={{ headerShown: false }}
/>
) : (
<Root.Screen
name={RootStackRoutes.UnauthenticatedStack}
component={UnauthenticatedStack}
options={{ headerShown: false }}
/>
)}
<Root.Screen
name={RootStackRoutes.ResetPasswordScreen}
component={ResetPasswordScreen}
/>
</Root.Navigator>
Constructing the Linking Object.
Using all of the components defined above, lets creating our linking
object that gets passed to our React Navigator. We’ll create a getInitialURL
method and prefix
to pass to the LinkingOptions
config object:
const getInitialURL = async () => {
const url = await Linking.getInitialURL();
if (url !== null) {
return parseSupabaseUrl(url);
}
return url;
};
const prefix = Linking.createURL("/");
const linking: LinkingOptions<RootStackParamsList> = {
prefixes: [prefix],
config: {
screens: {
ResetPasswordScreen: "/ResetPassword",
},
},
getInitialURL,
subscribe,
};
On top of that, we’ll also pass through the subscribe
function we defined above, and we define the deeplinking structure to have our ResetPasswordScreen
above. You can read more about deeplinking options in the React Navigation docs.
Final Result
In the end, our RootNavigator
file will look something like this:
import { createStackNavigator } from "@react-navigation/stack";
import { useAuth } from "contexts/Auth/AuthContext";
import { createRef, FC } from "react";
import { AuthenticatedStack } from "screens/AuthenticatedStack/AuthenticatedStack";
import { UnauthenticatedStack } from "screens/UnauthenticatedStack/UnauthenticatedStack";
import {
RootStackParamsList,
RootStackRoutes,
} from "screens/rootNavigator.routes";
import { MenuProvider } from "react-native-popup-menu";
import {
LinkingOptions,
NavigationContainer,
NavigationContainerRef,
} from "@react-navigation/native";
import * as Linking from "expo-linking";
import { ResetPasswordScreen } from "./AuthenticatedStack/ResetPasswordScreen/ResetPasswordScreen";
const Root = createStackNavigator<RootStackParamsList>();
const navigationRef = createRef<NavigationContainerRef<RootStackParamsList>>();
const prefix = Linking.createURL("/");
// Needs this for token parsing - since we're dealing with global shims, TS is going to be a little weird.
// eslint-disable-next-line
global.Buffer = global.Buffer || require("buffer").Buffer;
const parseSupabaseUrl = (url: string) => {
let parsedUrl = url;
if (url.includes("#")) {
parsedUrl = url.replace("#", "?");
}
return parsedUrl;
};
export const RootStack: FC = () => {
const { user, loginWithToken } = useAuth();
const isLoggedIn = user !== null;
const getInitialURL = async () => {
const url = await Linking.getInitialURL();
if (url !== null) {
return parseSupabaseUrl(url);
}
return url;
};
const subscribe = (listener: (url: string) => void) => {
const onReceiveURL = ({ url }: { url: string }) => {
const transformedUrl = parseSupabaseUrl(url);
const parsedUrl = Linking.parse(transformedUrl);
const access_token = parsedUrl.queryParams?.access_token;
const refresh_token = parsedUrl.queryParams?.refresh_token;
if (
typeof access_token === "string" &&
typeof refresh_token === "string"
) {
void loginWithToken({ access_token, refresh_token });
}
listener(transformedUrl);
};
const subscription = Linking.addEventListener("url", onReceiveURL);
return () => {
subscription.remove();
};
};
const linking: LinkingOptions<RootStackParamsList> = {
prefixes: [prefix],
config: {
screens: {
ResetPasswordScreen: "/ResetPassword",
},
},
getInitialURL,
subscribe,
};
return (
<NavigationContainer ref={navigationRef} linking={linking}>
<MenuProvider>
<Root.Navigator>
{isLoggedIn ? (
<Root.Screen
name={RootStackRoutes.AuthenticatedStack}
component={AuthenticatedStack}
options={{ headerShown: false }}
/>
) : (
<Root.Screen
name={RootStackRoutes.UnauthenticatedStack}
component={UnauthenticatedStack}
options={{ headerShown: false }}
/>
)}
<Root.Screen
name={RootStackRoutes.ResetPasswordScreen}
component={ResetPasswordScreen}
/>
</Root.Navigator>
</MenuProvider>
</NavigationContainer>
);
};
And that’s pretty much it! 🎉 You’ll need to:
- Add your app’s URL structure to the Supabase dashboard.
- After the user’s been redirected to the app, on the
PasswordResetScreen
, allow the user to enter a password and call the Supabase SDK’s reset password function.
Feel free to reach out
Feel free to reach out to me on Twitter @mo__javad. 🙂