OTP Authentication with Supabase and Twilio in React

OTP Authentication with Supabase and Twilio in React

Author: Vijit Ail

Introduction

Passwords are one of the most popular ways to authenticate a user. However, passwords have disadvantages, such as being subject to brute force attacks and data breaches.

Another significant problem with password-based login is that keeping track of different passwords can become challenging. This is where the term 'OTP' (One Time Password) can be helpful.

When we talk about OTP-based authentication, users need to enter a six-digit OTP number sent to them through an automated call or SMS when they want to access the application.

You can see the example app we'll build in the article from here

What is Supabase?

Supabase is an open-source Firebase alternative. It provides a real-time database, authentication, and media buckets in one platform. You can use Supabase with any frontend tool, such as React, Angular, or Vue.js.

One of the great features of Supabase is its Auth service. Supabase Auth allows you to easily add OTP-based authentication to your app with just a few lines of code.

In this guide, you will implement OTP-based login in Refine using the Supabase library.

What is refine?

refine is a React-based open-source frameworks for building admin panels, dashboards, internal tools and storefront apps rapidly. It helps developers to avoid from repetitive tasks demanded by CRUD operations and provides solutions for like authentication, access control, routing, networking, state management.

One of the great features of refine is its out-of-the-box data providers integrations. refine has a built-in data provider for supabase and we'll see how to use it propery.

Prerequisites

To follow this guide, you must install the latest Node.js version on your system and be familiar with React and TypeScript concepts. For this tutorial, you will also require a Twilio account to send out OTP text messages and a Github account to sign up for Supabase.

Getting Started

Start by creating the refine app using the superplate CLI.

npx superplate-cli -p refine-react refine-supabase-auth

terminal.png Choose the headless option while specifying the UI framework, as you will be integrating tailwind in this tutorial. You can select your preferred package manager; this tutorial will use yarn. Choose the supabase option when selecting the Data Provider.

Here is the source code of refine supabase data provider

Installing Tailwind CSS for refine project

Next, navigate to your project directory and install the following packages.

yarn add -D tailwindcss
yarn add daisyui react-daisyui

Daisy UI adds attractive component classes to Tailwind, which are customizable and comes with modular React components like Button, Card, etc., out-of-the-box.

Run the following command to initialize Tailwind in your project.

npx tailwindcss init

Update the recently added tailwind.config.js file to add some theming to the refine app.

// tailwind.config.js

module.exports = {
  content: [
    "node_modules/daisyui/dist/**/*.js",
    "node_modules/react-daisyui/dist/**/*.js",
    "./src/**/*.{js,jsx,ts,tsx}",
  ],
  theme: {
    extend: {
      colors: {
        dark: "#030303",
        gray: "#eaeaec",
      },
    },
  },
  plugins: [require("daisyui")],
  daisyui: {
    themes: [
      {
        mytheme: {
          primary: "#545bef",
          secondary: "#757EC0",
          accent: "#09f08a",
        },
      },
    ],
  },
};

Now, create the App.css file and add the following content.

// src/App.css

@tailwind base;
@tailwind components;
@tailwind utilities;

In the App.tsx file, import the App.css file to add the styling.

Run the yarn dev command to start the refine development server.

yarn dev

dashboard.png

Set up the Supabase Project

Head over to app.supabase.com and sign in to your Supabase account. Next, create a new project by clicking on the "New Project" button.

setupSupabase.png

Add the name of the project and the database password, and wait for Supabase to set up and create your project. Meanwhile, you can grab the public key and the project URL from the Supabase dashboard and update the credentials in your code.

Once the project is created, go to Authentication -> Settings to configure the Auth providers.

supabase2.png

supabase3.png

You will find the Phone Auth option in the Auth providers section; enable it and select Twilio as the SMS provider.

supabase4.png

You need to create and developer account and set up credentials on Twilio Console

Add your Twilio API credentials to complete the integration. You can also edit the OTP expiry time, length of the OTP, and the SMS template according to your business requirements. For this guide, you can stick with the default values.

The backend setup is now complete. In the next section, you will start building the app's frontend.


Create the Login Page

In this guide, you are allowing users to access their account without requiring a password. Once the users log into their account, they will see a list of countries on the dashboard screen.

On the login page, you need to create a two-step form.
In the first step, the user will enter the mobile number to receive the OTP message, and in the second step, the user will enter the OTP token to log in. Display an error if the OTP token is invalid or expired.

// src/pages/Login.tsx

import { useRef, useState } from "react";
import { Alert, Button, Card, Input } from "react-daisyui";

export const LoginPage = () => {
  const mobileNoRef = useRef<string>();
  const otpRef = useRef<string>();
  const [error, setError] = useState<string>();
  const [formState, setFormState] = useState<"SEND_OTP" | "LOGIN">("SEND_OTP");

  const onSendOtp = () => {
    setFormState("LOGIN");
  };

  const mobileFormRender = () => (
    <>
      <label className="font-medium text-dark">Enter your mobile mumber</label>
      <Input
        className="mb-4 border-gray bg-gray text-dark text-lg font-medium"
        onChange={(e) => (mobileNoRef.current = e.target.value)}
        onFocus={() => setError("")}
        name="mobile"
        type={"tel"}
        defaultValue={mobileNoRef.current}
      />
      <Button color="accent" className="shadow" onClick={onSendOtp}>
        Send OTP
      </Button>
    </>
  );

  const otpFormRender = () => (
    <>
      <label className="font-medium text-dark">Enter OTP</label>
      <Input
        className="mb-4 border-gray bg-gray text-dark text-lg font-medium"
        onChange={(e) => (otpRef.current = e.target.value)}
        onFocus={() => setError("")}
        name="otp"
        value={otpRef.current}
      />
      <Button color="accent" className="shadow" onClick={onLogin}>
        Login
      </Button>
    </>
  );

  return (
    <div className="min-h-screen bg-primary flex items-center justify-center">
      <Card className="bg-white w-1/2 shadow-lg " bordered={false}>
        <Card.Body>
          {error && (
            <Alert status="error" className="mb-2">
              {error}
            </Alert>
          )}
          <h2 className="text-dark text-xl  font-bold mb-3">Sign In</h2>
          {formState === "SEND_OTP" && mobileFormRender()}
          {formState === "LOGIN" && otpFormRender()}
        </Card.Body>
      </Card>
    </div>
  );
};

In the above code, we set a formState state variable to define whether to render the mobile input or the OTP input on the screen. If there's any error, set the error variable and display it using the Alert component.

Import the LoginPage component in the App.tsx file and pass it as a prop to the <Refine/> component to override the Login page.

// App.tsx

import { Refine } from '@pankod/refine-core';
import routerProvider from '@pankod/refine-react-router-v6';
import { dataProvider } from '@pankod/refine-supabase';
import { supabaseClient } from 'utility';
import authProvider from './authProvider';
import { Countries } from 'pages/Countries';
import { Layout } from 'pages/Layout';
import './App.css';
// ==>
import { LoginPage } from 'pages/Login';
// <==

function App() {
  return (
    <Refine
      routerProvider={routerProvider}
      dataProvider={dataProvider(supabaseClient)}
      resources={[{ name: "countries" }]}
      authProvider={authProvider}
      // ==>
      LoginPage={LoginPage}
     // <==
    />
  );
}

Also, notice that superplate CLI has already imported the authProvider and dataProvider for you.

Data Provider

The dataProvider acts as a data layer for your app that makes the HTTP requests and encapsulates how the data is retrieved. It requests and returns the data using predefined methods like create(), getMany(), etc. Refine consumes these methods via data hooks.

For example, when you use the useList hook, Refine internally calls the getList() method of the data provider.

In this case, we pass the supabaseClient as the data provider. Supabase is supported out-of-the-box as a data provider by Refine. Here, the data provider internally calls supabase-js database methods like select(), insert(), etc., to handle the data.

You can learn more about data provider in the Refine docs.

Auth Provider

The authProvider is an object that refine uses to authenticate and authorize the users. The auth provider must have methods like login(), register(), etc., to manage authentication in your app. These methods should return a Promise and are accessible via hooks.

The superplate CLI autogenerates the auth provider from your selected preference- in this case, it is Supabase. Unlike data providers, refine does not offer out-of-the-box support for auth providers; you must create it from scratch.

You can read more about auth provider in detail here.

Alright, now coming back to the LoginPage component. When the user requests for OTP, validate the mobile number using the regex shown in the below code. The mobile number is expected to include the country code; you can use other third-party components for mobile input with a country code dropdown and mobile validation out-of-the-box.

We'll use the input field in this guide for brevity.

// src/pages/Login.tsx

import { supabaseClient } from "utility";

...

const onSendOtp = async () => {
  const mobileNo = mobileNoRef.current || "";
  if (!/^\+[1-9]{1}[0-9]{3,14}$/.test(mobileNo)) {
    setError("Please enter a valid mobile number");
    return;
  }
  const { error } = await supabaseClient.auth.signIn({
    phone: mobileNo,
  });
  if (error) {
    setError(error.message);
    return;
  }
  setFormState("LOGIN");
};

...

To send the OTP message to the user, use the supabase.auth.signIn() method and pass the mobile number in the phone property as shown above.

login.png

loginInvalid.png

Update the login property in authProvider to accept the mobile number and OTP as input and call the supabase.auth.verifyOTP() method for verifying the OTP entered by the user and enabling access to the dashboard page of the app.

// src/authProvider.ts
...

const authProvider: AuthProvider = {
  login: async ({ mobileNo, otp }) => {
    const { user, error } = await supabaseClient.auth.verifyOTP({
      phone: mobileNo,
      token: otp,
    });
    if (error) {
      return Promise.reject(error);
    }
    if (user) {
      return Promise.resolve();
    }
  },
  ...
}

In the onLogin() function of the <LoginPage/> component, pass the mobile number and OTP to the login() acquired from the useLogin hook.

// src/pages/Login.tsx

...

const { mutate: login } = useLogin();

const onLogin = () => {
  login(
    { mobileNo: mobileNoRef.current, otp: otpRef.current },
    { onError: (error) => setError(error.message) }
  );
};

If the OTP is invalid, the error message will be displayed as shown below.

loginToken.png

The authentication flow is now complete. Let’s finish the rest of the app by creating the countries resource.

In your Supabase project, head to the SQL editor page and click on the “Countries” option from the Quick start section. It will open up the SQL statements in an editor page; click on the RUN button to execute them.

supabaseDB.png

supabaseDB2.png

The SQL snippet will create a countries table and dump the country list and other columns like country code and continent.

In the Countries component, get the data from Supabase using the useList hook and render the data using the Table component.

// src/pages/Countries.tsx

import { useList } from "@pankod/refine-core";
import { Table } from "react-daisyui";

const columns = ["ID", "Name", "ISO Code", "Local Name", "Continent"];

export const Countries = () => {
  const { data: countries } = useList({
    resource: "countries",
    config: { hasPagination: false },
  });
  return (
    <div className="overflow-x-auto">
      <Table color="primary" className="w-full">
        <Table.Head className="bg-primary">
          {columns.map((column) => (
            <span key={column}>{column}</span>
          ))}
        </Table.Head>
        <Table.Body>
          {countries?.data.map((country: Record<string, string>) => (
            <Table.Row key={country.id}>
              <span className="text-dark opacity-50 font-medium">
                {country.id}
              </span>
              <span className="text-dark opacity-50 font-medium">
                {country.name}
              </span>
              <span className="text-dark opacity-50 font-medium">
                {country.iso2}
              </span>
              <span className="text-dark opacity-50 font-medium">
                {country.local_name}
              </span>
              <span className="text-dark opacity-50 font-medium">
                {country.continent}
              </span>
            </Table.Row>
          ))}
        </Table.Body>
      </Table>
    </div>
  );
};

Create the Layout component to create an app bar with a logout button.

// src/pages/Layout.tsx

import { LayoutProps, useLogout } from "@pankod/refine-core";
import { Button } from "react-daisyui";

export const Layout: React.FC<LayoutProps> = ({ children }) => {
  const { mutate: logout } = useLogout();
  return (
    <div className="flex min-h-screen flex-col">
      <div className="mb-2 py-3 bg-gray">
        <div className="container mx-auto flex">
          <Button
            color="accent"
            size="sm"
            className="ml-auto shadow"
            onClick={() => logout()}
          >
            Logout
          </Button>
        </div>
      </div>
      <div className="container bg-white mx-auto py-4">{children}</div>
    </div>
  );
};

Import the Countries and the Layout component in the App.tsx file to finish up the application.

// App.tsx

...
// ==>
import { Countries } from "pages/Countries";
import { Layout } from "components/Layout";
// <==

function App() {
  return (
    <Refine
      routerProvider={{
        ...routerProvider,
      }}
      dataProvider={dataProvider(supabaseClient)}
      authProvider={authProvider}
      LoginPage={LoginPage}
      // ==>
      resources={[{ name: "countries", list: Countries }]}
      Layout={Layout}
      // <==
    />
  );
}
final


final.png

Conclusion

OTP authentication adds an extra layer of security to your application and helps ensure that only authorized users can access it. In this article, we've gone over how to add OTP-based authentication in refine using Supabase Auth. We've also looked at how to set up the phone auth provider in Supabase using Twilio so that users can receive their OTP tokens.

Following this article's steps, you should now have a refine application with OTP-based authentication enabled.



Build your React-based CRUD applications without constraints

Low-code React frameworks are great for gaining development speed but they often fall short of flexibility if you need extensive styling and customization for your project.

Check out refine,if you are interested in a headless framework you can use with any custom design or UI-Kit for 100% control over styling.


refine is an open-source React-based framework for building CRUD applications without constraints. It can speed up your development time up to 3X without compromising freedom on styling, customization and project workflow.

refine is headless by design and it connects 30+ backend services out-of-the-box including custom REST and GraphQL API’s.

Visit refine GitHub repository for more information, demos, tutorials, and example projects.