How to Implement OTP-Based Email Login with Supabase

Introduction

In this article, we'll explore how to implement a secure One-Time Password (OTP) based login system using Supabase Edge Functions and Next.js. This approach provides an additional layer of security beyond traditional username and password authentication.

Prerequisites

Before we begin, ensure you have:

  • A Supabase account
  • Node.js installed
  • Supabase CLI installed (npm install -g supabase)
  • A Next.js project set up
  • A Resend account for email services

Setting Up the Supabase Edge Function

Let's start by creating and deploying our Supabase Edge Function:

  1. Open your terminal and navigate to your project directory.

  2. Log in to your Supabase account:

    supabase login
    
  3. Initialize Supabase in your project:

    supabase init
    
  4. Link your Supabase project:

    supabase link --project-ref your-project-ref
    

    Replace your-project-ref with your actual Supabase project reference.

  5. Create a new function:

    supabase functions new login-with-otp
    
  6. Navigate to the new function directory:

    cd supabase/functions/login-with-otp
    
  7. Replace the content of index.ts with the following code:

import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers':
    'authorization, x-client-info, apikey, content-type',
}

serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }

  try {
    const { username, password } = await req.json()

    const supabaseUrl = Deno.env.get('SUPABASE_URL')!
    const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
    const supabase = createClient(supabaseUrl, supabaseKey)

    const { data: userData, error: userError } = await supabase
      .from('users')
      .select('email, phone_number')
      .or(`username.eq.${username},email.eq.${username}`)
      .limit(1)
      .maybeSingle()

    if (userError || !userData) {
      return new Response(JSON.stringify({ error: 'Invalid credentials' }), {
        status: 400,
        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
      })
    }

    const {
      data: { session },
      error: authError,
    } = await supabase.auth.signInWithPassword({
      email: userData.email,
      password: password,
    })

    if (authError) {
      console.error('Error signing in:', authError)
      return new Response(JSON.stringify({ error: 'Invalid credentials' }), {
        status: 401,
        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
      })
    }

    // Generate OTP
    const OTP = Math.floor(100000 + Math.random() * 900000)

    // Send Email using Resend
    const sendEmail = await fetch('https://api.resend.com/emails', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${Deno.env.get('RESEND_API_KEY')}`,
      },
      body: JSON.stringify({
        from: 'Your App <no-reply@yourapp.com>',
        to: [`${userData.email}`],
        subject: 'Verification Code',
        html: `<strong>Your verification code is: ${OTP}</strong>`,
      }),
    })

    console.log(sendEmail)

    return new Response(
      JSON.stringify({
        session,
        phone_number: userData.phone_number,
        email: userData.email,
        otp: OTP,
      }),
      { headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
    )
  } catch (error) {
    console.error('Error:', error)
    return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
      status: 500,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
    })
  }
})
  1. Deploy the function:

    supabase functions deploy login-with-otp
    
  2. Set the necessary environment variables:

    supabase secrets set RESEND_API_KEY=your_resend_api_key
    

    Replace your_resend_api_key with your actual Resend API key.

Understanding the Edge Function

Let's break down the key components of our Edge Function:

CORS Headers

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers':
    'authorization, x-client-info, apikey, content-type',
}

These headers allow cross-origin requests, which is necessary if your frontend is hosted on a different domain than your Supabase project. The '*' in Access-Control-Allow-Origin allows requests from any origin, which you might want to restrict in production.

Request Handling

serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }
  // ... rest of the function
})

This sets up the server to handle incoming requests. The OPTIONS check is part of the CORS preflight request handling.

User Authentication

const { data: userData, error: userError } = await supabase
  .from('users')
  .select('email, phone_number')
  .or(`username.eq.${username},email.eq.${username}`)
  .limit(1)
  .maybeSingle()

// ... check for errors

const {
  data: { session },
  error: authError,
} = await supabase.auth.signInWithPassword({
  email: userData.email,
  password: password,
})

This section queries the users table to find a user with the provided username or email, then attempts to sign in using the provided password.

OTP Generation and Sending

const OTP = Math.floor(100000 + Math.random() * 900000)

const sendEmail = await fetch('https://api.resend.com/emails', {
  // ... email sending logic
})

Here, we generate a 6-digit OTP and send it to the user's email using the Resend service.

Response

return new Response(
  JSON.stringify({
    session,
    phone_number: userData.phone_number,
    email: userData.email,
    otp: OTP,
  }),
  { headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
)

The function returns the session data, user contact information, and the generated OTP. In a production environment, you would typically not send the OTP back to the client, but instead store it securely for verification.

Implementing the Frontend

Now that our Edge Function is set up, let's create a simple Next.js component to handle the login process:

import { useState } from 'react'
import { supabase } from '../lib/supabase'

const Login = () => {
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const [otp, setOtp] = useState('')
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')
  const [session, setSession] = useState(null)
  const [showOtpInput, setShowOtpInput] = useState(false)

  const handleLogin = async (e) => {
    e.preventDefault()
    setLoading(true)
    setError('')

    try {
      const response = await fetch(
        'https://your-project-ref.supabase.co/functions/v1/login-with-otp',
        {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ username, password }),
        },
      )

      const data = await response.json()

      if (response.ok) {
        setSession(data.session)
        setShowOtpInput(true)
      } else {
        setError(data.error || 'Login failed')
      }
    } catch (error) {
      setError('An error occurred')
    } finally {
      setLoading(false)
    }
  }

  const handleOtpVerification = async (e) => {
    e.preventDefault()
    setLoading(true)
    setError('')

    try {
      // Here you would typically verify the OTP
      // For this example, we'll just simulate a successful verification
      console.log('OTP verified:', otp)
      // Use the session to log in
      const { error } = await supabase.auth.setSession(session)
      if (error) throw error
      // Redirect or update UI as needed
    } catch (error) {
      setError('OTP verification failed')
    } finally {
      setLoading(false)
    }
  }

  return (
    <div>
      <h1>Login</h1>
      {!showOtpInput ? (
        <form onSubmit={handleLogin}>
          <input
            type="text"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            placeholder="Username or Email"
            required
          />
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            placeholder="Password"
            required
          />
          <button type="submit" disabled={loading}>
            {loading ? 'Logging in...' : 'Login'}
          </button>
        </form>
      ) : (
        <form onSubmit={handleOtpVerification}>
          <input
            type="text"
            value={otp}
            onChange={(e) => setOtp(e.target.value)}
            placeholder="Enter OTP"
            required
          />
          <button type="submit" disabled={loading}>
            {loading ? 'Verifying...' : 'Verify OTP'}
          </button>
        </form>
      )}
      {error && <p>{error}</p>}
    </div>
  )
}

export default Login

This component handles both the initial login and OTP verification steps.

Conclusion

In this article, we've implemented a secure OTP-based login system using Supabase Edge Functions and Next.js. This approach provides an additional layer of security by requiring a one-time password after the initial authentication.

Remember to handle edge cases, add proper error handling, and implement additional security measures as needed for your specific application. Also, in a production environment, you should store and verify the OTP securely, rather than sending it back to the client.

By leveraging Supabase Edge Functions, we can create powerful, serverless authentication flows that enhance the security of our applications.