Phone Authentication with Twilio, NextJS and Supabase

Phone Authentication with Twilio, NextJS and Supabase


About the author

Telmo Goncalves is a software engineer with over 13 years of software development experience and an expert in React. He’s currently Engineering Team Lead at Marley Spoon.

Check out more of his work on telmo.is and follow him on Twitter @telmo


images/how-to-set-up-phone-sms-verification-using-twilio-nextjs-supabase.jpg

This article expands on Authentication with NextJS and Supabase. It covers how to allow users to log in with their mobile phone using Twilio. Phone authentication is also very commonly used as a two-factor authentication (2FA); it’s very commonly used in consumer applications for user security. No experience with Twilio is required, but if you’re new to software development I strongly encourage working through this exercise first.

We’ll be building on the same repository for implementing SMS OTP (one-time password), so make sure to use the same code as a foundation.

What this covers:

  • Creating and setting up a Twilio account. There’s no cost required for this tutorial.
  • Setting up Supabase with Twilio credentials.
  • Implementing login functionality with phone number.

Twilio

Navigate to Twilio and create an account. Once you’re done setting up everything you’ll need to request a trial phone number, scroll down and click on Get a trial phone number:

images/how-to-set-up-twilio-trial.jpg

You should see a messaging reading something like:

Your new Phone Number is +13345∙∙∙∙∙∙

The format of the phone number may vary depending on your region.

You should now see the credentials we’ll be using over at Supabase:

images/phone-auth-with-supabase-project-info.jpg

Supabase

Access your Supabase account, navigate to Settings → Auth settings, under Phone Auth toggle Enable Phone Signup.

images/phone-auth-with-supabase-twilio.jpg

Fill in the details with Twilio credentials accordingly. Click on Save.


The Application

Now the existing application can be updated to add the ability to sign up either with email or with a phone number.

Open pages/auth.tsx - the file should look like this:

import { useState } from 'react'
import { useRouter } from 'next/router'

import supabase from '../lib/supabase'

const Auth: React.FC = () => {
  const [email, setEmail] = useState<string>()
  const { push } = useRouter()

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    const { error } = await supabase.auth.signIn({ email })

    if (!error) push('/')
  }

  return (
    <div className="border rounded-lg p-12 w-4/12 mx-auto my-48">
      <h3 className="font-extrabold text-3xl">Ahoy!</h3>

      <p className="text-gray-500 text-sm mt-4">
        Fill in your email, we'll send you a magic link.
      </p>

      <form onSubmit={handleSubmit}>
        <input
          type="email"
          placeholder="Your email address"
          className="border w-full p-3 rounded-lg mt-4 focus:border-indigo-500"
          onChange={e => setEmail(e.target.value)}
        />

        <button
          type="submit"
          className="bg-indigo-500 text-white w-full p-3 rounded-lg mt-8 hover:bg-indigo-700"
        >
          Let's go!
        </button>
      </form>
    </div>
  )
}

export default Auth

First, we’ll need to add 2 new states:

  • One to save the phone number value.
  • One to toggle between email and phone number as the signup method.
import { useState } from 'react'
import { useRouter } from 'next/router'

import supabase from '../lib/supabase'

const Auth: React.FC = () => {
  const [email, setEmail] = useState<string>()
  const [phone, setPhone] = useState<string>()
  const [signupWithPhone, setSignupWithPhone] = useState(false)
  const { push } = useRouter()

  // ...
}

export default Auth

With the new states added, we’ll need to add a new <input> for the user’s phone number:

<form onSubmit={handleSubmit}>
  <input
    type="text"
    placeholder="Your phone number"
    className="border w-full p-3 rounded-lg mt-4 focus:border-indigo-500"
    onChange={e => setPhone(e.target.value)}
  />

  <input
    type="email"
    placeholder="Your email address"
    className="border w-full p-3 rounded-lg mt-4 focus:border-indigo-500"
    onChange={e => setEmail(e.target.value)}
  />

  <button
    type="submit"
    className="bg-indigo-500 text-white w-full p-3 rounded-lg mt-8 hover:bg-indigo-700"
  >
    Let's go!
  </button>
</form>

At this point, both the email and phone number inputs will be visible at the same time. This can be confusing since the user may think that they’ll need to provide both. We should make it clear to the user that either option can be used (not both), so we’ll need to display the input depending on the value of signupWithPhone.

To do that, add the following:

<form onSubmit={handleSubmit}>
  {signupWithPhone ? (
    <input
      type="text"
      placeholder="Your phone number"
      className="border w-full p-3 rounded-lg mt-4 focus:border-indigo-500"
      onChange={e => setPhone(e.target.value)}
    />
  ) : (
    <input
      type="email"
      placeholder="Your email address"
      className="border w-full p-3 rounded-lg mt-4 focus:border-indigo-500"
      onChange={e => setEmail(e.target.value)}
    />
  )}

  <button
    type="submit"
    className="bg-indigo-500 text-white w-full p-3 rounded-lg mt-8 hover:bg-indigo-700"
  >
    Let's go!
  </button>
</form>

Now we just need to create a button to allow the user to switch between the 2 input options:

<form onSubmit={handleSubmit}>
  {/* ... */}

  <div className="my-4">
    <button
      className="text-sm text-gray-500 hover:underline"
      onClick={() => setSignupWithPhone(!signupWithPhone)}
    >
      Use {signupWithPhone ? 'email' : 'phone'} instead
    </button>
  </div>

  <button
    type="submit"
    className="bg-indigo-500 text-white w-full p-3 rounded-lg mt-8 hover:bg-indigo-700"
  >
    Let's go!
  </button>
</form>

This will allow the user to select email or phone as the signup option. Every time the button is clicked, it will set the value of signupWithPhone to the opposite value.

The way booleans work with this technique:

let signupWithPhone = true;

!signupWithPhone; // false
!signupWithPhone; // true
!signupWithPhone; // false
!signupWithPhone; // true

So a different context will be displayed depending if signupWithPhone is true (sign up with phone) or false (sign up with email).

Awesome, we’ll need to make a small update to the handleSubmit function:

const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault()

  const { error } = await supabase.auth.signIn({
    ...signupWithPhone ? { phone } : { email },
  })

  if (!error) push('/')
}

This is a cool trick. Since Supabase uses the same signIn() for both email and phone, we can simply check if the signup is being initialized using the phone option, otherwise we’re sending the value of email. Voilà!


OTP Code

OTP authentication via phone uses a verification code to ensure the phone number is valid. So the application needs a way of verifying that the code sent to their device is valid. So we’ll need to update where the user is directed when they initiate a signup using their phone.

For the sake of code cleanliness, it’s best to create a new, dedicated component for this:

touch pages/verify-otp.tsx

Inside the file, create a simple component:

const VerifyOTP: React.FC = () => {
  return (
    <>
      We'll verify OTP here!
    </>
  )
}

export default VerifyOTP

Great, now we need to make a small change on our pages/auth.tsx component:

const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault()

  const { error } = await supabase.auth.signIn({
    ...signupWithPhone ? { phone } : { email },
  })

  if (!error) push(signupWithPhone ? '/verify-otp' : '/')
}

Now when authenticating with phone we’ll redirect the user to verify the OTP code.

Back in VerifyOTP, we’ll create the input where the user can enter their verification code:

const VerifyOTP: React.FC = () => {
  return (
    <div className="border rounded-lg p-12 w-4/12 mx-auto my-48">
      <h3 className="font-extrabold text-3xl">Verify OTP</h3>

      <p className="text-gray-500 text-sm mt-4">
        You should've received an SMS with a code.
      </p>

      <form>
        <input
          type="token"
          placeholder="Your OTP code"
          className="border w-full p-3 rounded-lg mt-4 focus:border-indigo-500"
        />

        <button
          type="submit"
          className="bg-indigo-500 text-white w-full p-3 rounded-lg mt-8 hover:bg-indigo-700"
        >
          Verify
        </button>
      </form>
    </div>
  )
}

export default VerifyOTP

Now we need to save the value of our input and handle the form submission:

import { useState } from 'react'
import { useRouter } from 'next/router'

import supabase from '../lib/supabase'

const VerifyOTP: React.FC = () => {
  const [token, setToken] = useState<string>('')
  const phone = 'YOUR_PHONE_NUMBER'
  const { push } = useRouter()

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    const { session, error } = await supabase.auth.verifyOTP({ phone, token })

    console.log({ session })

    if (!error) push('/')
  }

  return (
    <div className="border rounded-lg p-12 w-4/12 mx-auto my-48">
      <h3 className="font-extrabold text-3xl">Verify OTP</h3>

      <p className="text-gray-500 text-sm mt-4">
        You should've received an SMS with a code.
      </p>

      <form onSubmit={handleSubmit}>
        <input
          type="token"
          placeholder="Your OTP code"
          className="border w-full p-3 rounded-lg mt-4 focus:border-indigo-500"
          onChange={e => setToken(e.target.value)}
        />

        <button
          type="submit"
          className="bg-indigo-500 text-white w-full p-3 rounded-lg mt-8 hover:bg-indigo-700"
        >
          Verify
        </button>
      </form>
    </div>
  )
}

export default VerifyOTP

You might notice that I’m setting the phone number as a raw value, there are several ways this can be handled, I don’t want to go through any because it’s not the point of this article, but you could save the value of the phone number with localStorage, or have all this logic in one single component.

That’s it! Everything should be working as expected. Go through the whole flow and you should get a session object back from Supabase.


Telmo regularly posts helpful React development tips and guides on Twitter. Be sure to follow him at @telmo


About PullRequest

HackerOne PullRequest is a platform for code review, built for teams of all sizes. We have a network of expert engineers enhanced by AI, to help you ship secure code, faster.

Learn more about PullRequest

Telmo Goncalves headshot
by Telmo Goncalves

September 7, 2021