Skip to content

Handling Supabase Password Reset in React Native

March 29, 2023Mo Khazali7 min read

Supabase and React Logos

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:

  1. Requesting a password reset for a given email.
  2. Automatically logging the user into the application.
  3. 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.

React Native Users on Github facing issues 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:

  1. Requesting Password Reset
  2. Token-based Login
  3. Handling Deep Linking
  4. Authenticating the User as Part of the Deeplink
  5. 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:

Feel free to reach out

Feel free to reach out to me on Twitter @mo__javad. 🙂