Phone number authentication with OTP is the primary authentication method for the Saudi Arabian market. This guide shows you how to implement passwordless authentication using SMS verification codes.
Overview
Phone OTP authentication provides a secure, passwordless login experience:
- User enters their phone number
- System sends a 6-digit OTP via SMS
- User verifies the OTP
- System creates or logs in the user
Setup
Arraf Auth requires an SMS provider to send OTP codes. Implement the SMSProvider interface:
import { createAuth, SMSProvider } from '@arraf-auth/core'
const smsProvider: SMSProvider = {
async send({ to, message }) {
// Example using Twilio
const response = await fetch('https://api.twilio.com/2010-04-01/Accounts/ACCOUNT_SID/Messages.json', {
method: 'POST',
headers: {
'Authorization': 'Basic ' + btoa('ACCOUNT_SID:AUTH_TOKEN'),
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
To: to,
From: '+966XXXXXXXXX',
Body: message
})
})
const data = await response.json()
return {
success: response.ok,
messageId: data.sid,
error: data.message
}
}
}
Initialize Auth with OTP Config
Configure OTP settings and add the SMS provider:
import { createAuth } from '@arraf-auth/core'
import { prismaAdapter } from '@arraf-auth/adapter-prisma'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const auth = createAuth({
secret: process.env.AUTH_SECRET!,
database: prismaAdapter(prisma),
sms: smsProvider,
otp: {
length: 6,
expiresIn: 300, // 5 minutes
maxAttempts: 5,
messageTemplate: (otp, appName) => {
return `رمز التحقق الخاص بك هو: ${otp}\nYour ${appName} verification code is: ${otp}\nValid for 5 minutes.`
}
}
})
Create two endpoints for sending and verifying OTP:
// app/api/auth/otp/send/route.ts
import { auth } from '@/lib/auth'
export async function POST(req: Request) {
return auth.handler('/auth/otp/send', req)
}
// app/api/auth/otp/verify/route.ts
export async function POST(req: Request) {
return auth.handler('/auth/otp/verify', req)
}
Phone Number Normalization
Arraf Auth automatically normalizes Saudi phone numbers to E.164 format:
// Input formats that are normalized:
"0512345678" → "+966512345678"
"00966512345678" → "+966512345678"
"966512345678" → "+966512345678"
"+966512345678" → "+966512345678" (already normalized)
The normalization happens in packages/core/src/phone.ts:7-27 and supports:
- Saudi local format (05XXXXXXXX)
- International prefix (00966)
- Country code without + (966)
- E.164 format (+966)
Phone numbers must be unique in the database. The normalized format is stored and used for all lookups.
Sending OTP
Send an OTP to a phone number:
Request
POST /auth/otp/send
Content-Type: application/json
{
"method": "phone",
"phone": "0512345678"
}
Response
{
"success": true,
"message": "OTP sent to phone",
"maskedPhone": "+9665****5678"
}
Error Responses
// Invalid phone number
{
"error": "Invalid phone number format"
}
// SMS provider not configured
{
"error": "SMS provider not configured"
}
Verifying OTP
Verify the OTP and create/login the user:
Request
POST /auth/otp/verify
Content-Type: application/json
{
"method": "phone",
"phone": "0512345678",
"otp": "123456",
"name": "أحمد محمد" // Optional: only used for new users
}
Response
{
"user": {
"id": "user_abc123",
"phone": "+966512345678",
"email": null,
"name": "أحمد محمد"
},
"session": {
"id": "session_xyz789",
"token": "eyJhbGc...",
"expiresAt": "2024-12-31T23:59:59Z"
},
"isNewUser": true
}
The response includes a Set-Cookie header with the session token.
Error Responses
// Invalid OTP
{
"error": "Invalid OTP. 4 attempts remaining."
}
// Expired OTP
{
"error": "OTP expired. Please request a new one."
}
// Too many attempts
{
"error": "Too many attempts. Please request a new OTP."
}
After 5 failed attempts, the OTP is deleted and the user must request a new one. This is configured via otp.maxAttempts in the auth config.
Client Implementation
Example React hook for phone OTP authentication:
import { useState } from 'react'
export function usePhoneAuth() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [otpSent, setOtpSent] = useState(false)
const sendOTP = async (phone: string) => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/auth/otp/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: 'phone', phone })
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Failed to send OTP')
}
setOtpSent(true)
return data
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
throw err
} finally {
setLoading(false)
}
}
const verifyOTP = async (phone: string, otp: string, name?: string) => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/auth/otp/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: 'phone', phone, otp, name })
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Invalid OTP')
}
return data
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
throw err
} finally {
setLoading(false)
}
}
return { sendOTP, verifyOTP, loading, error, otpSent }
}
Security Considerations
Implement rate limiting to prevent SMS abuse:
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(3, '10 m'), // 3 requests per 10 minutes
})
export async function POST(req: Request) {
const ip = req.headers.get('x-forwarded-for') ?? 'unknown'
const { success } = await ratelimit.limit(ip)
if (!success) {
return Response.json({ error: 'Too many requests' }, { status: 429 })
}
return auth.handler('/auth/otp/send', req)
}
Phone Verification Status
When a user verifies their OTP, the phoneVerified field is automatically set to true (see otp-verify.ts:88-90):
if (isPhone && !user.phoneVerified) {
user = await ctx.adapter.updateUser(user.id, { phoneVerified: true })
}
Use hooks to track OTP verification events:
const auditPlugin = {
id: 'audit',
hooks: {
afterOTPVerified: async (user, type) => {
await logAuditEvent({
userId: user.id,
event: 'otp_verified',
verificationType: type,
timestamp: new Date()
})
}
}
}
const auth = createAuth({
// ... other config
plugins: [auditPlugin]
})
Best Practices
For Saudi Arabia:
- Use bilingual messages (Arabic + English) in OTP templates
- Set OTP expiry to 5 minutes (300 seconds)
- Use 6-digit codes for better user experience
- Display masked phone numbers for privacy
- Always normalize phone numbers - The system does this automatically, but ensure your UI accepts various formats
- Clear error messages - Show remaining attempts and clear instructions
- Resend functionality - Delete old OTP before sending new one (handled automatically)
- Session management - Cookie is set automatically after successful verification
- User creation - New users are created automatically on first OTP verification
Customization
Customize the OTP message template for your brand:
otp: {
messageTemplate: (otp, appName) => {
return `
🔐 ${appName}
رمز التحقق: ${otp}
Verification code: ${otp}
صالح لمدة 5 دقائق
Valid for 5 minutes
لا تشارك هذا الرمز مع أحد
Do not share this code
`.trim()
}
}
Next Steps