Skip to main content
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:
  1. User enters their phone number
  2. System sends a 6-digit OTP via SMS
  3. User verifies the OTP
  4. System creates or logs in the user

Setup

1
Configure SMS Provider
2
Arraf Auth requires an SMS provider to send OTP codes. Implement the SMSProvider interface:
3
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
    }
  }
}
4
Initialize Auth with OTP Config
5
Configure OTP settings and add the SMS provider:
6
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.`
    }
  }
})
7
Add API Routes
8
Create two endpoints for sending and verifying OTP:
9
// 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

1
Rate Limiting
2
Implement rate limiting to prevent SMS abuse:
3
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)
}
4
Phone Verification Status
5
When a user verifies their OTP, the phoneVerified field is automatically set to true (see otp-verify.ts:88-90):
6
if (isPhone && !user.phoneVerified) {
  user = await ctx.adapter.updateUser(user.id, { phoneVerified: true })
}
7
Plugin Hooks
8
Use hooks to track OTP verification events:
9
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
  1. Always normalize phone numbers - The system does this automatically, but ensure your UI accepts various formats
  2. Clear error messages - Show remaining attempts and clear instructions
  3. Resend functionality - Delete old OTP before sending new one (handled automatically)
  4. Session management - Cookie is set automatically after successful verification
  5. 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

Build docs developers (and LLMs) love