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:
-
Open your terminal and navigate to your project directory.
-
Log in to your Supabase account:
supabase login
-
Initialize Supabase in your project:
supabase init
-
Link your Supabase project:
supabase link --project-ref your-project-ref
Replace
your-project-ref
with your actual Supabase project reference. -
Create a new function:
supabase functions new login-with-otp
-
Navigate to the new function directory:
cd supabase/functions/login-with-otp
-
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' },
})
}
})
-
Deploy the function:
supabase functions deploy login-with-otp
-
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.