Quick Start
All auth integrations follow the same pattern:- Generate a verification code
- Store it securely with expiry
- Send via Unosend SMS API
- Verify the code on callback
NextAuth.js
Implement SMS OTP authentication with NextAuth.js.Copy
// pages/api/auth/[...nextauth].ts
import NextAuth from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
export default NextAuth({
providers: [
CredentialsProvider({
id: 'sms-otp',
name: 'SMS OTP',
credentials: {
phone: { label: "Phone", type: "text" },
code: { label: "Code", type: "text" }
},
async authorize(credentials) {
// Verify OTP from your database
const user = await verifyOTP(credentials.phone, credentials.code)
return user || null
}
})
]
})
// Send OTP function
async function sendOTP(phone) {
const otp = Math.floor(100000 + Math.random() * 900000)
// Store OTP in database with 5-minute expiry
await db.otps.create({
phone,
code: otp,
expires: new Date(Date.now() + 300000)
})
// Send via Unosend
await fetch('https://www.unosend.co/api/v1/sms', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.UNOSEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: phone,
body: `Your login code is ${otp}. Valid for 5 minutes.`,
metadata: { type: 'login' }
})
})
}
Clerk
Add SMS verification to Clerk authentication.Copy
// app/api/clerk-sms/route.ts
import { clerkClient } from '@clerk/nextjs/server'
export async function POST(req) {
const { userId, phone } = await req.json()
// Generate OTP
const otp = Math.floor(100000 + Math.random() * 900000)
// Store in Clerk user metadata
await clerkClient.users.updateUserMetadata(userId, {
privateMetadata: {
smsOtp: otp,
smsOtpExpiry: Date.now() + 300000
}
})
// Send via Unosend
const response = await fetch('https://www.unosend.co/api/v1/sms', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.UNOSEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: phone,
body: `Your Clerk verification code is ${otp}`,
metadata: { userId }
})
})
const data = await response.json()
return Response.json({ success: true, messageId: data.data[0].id })
}
// Verify endpoint
export async function PATCH(req) {
const { userId, code } = await req.json()
const user = await clerkClient.users.getUser(userId)
const storedOtp = user.privateMetadata.smsOtp
const expiry = user.privateMetadata.smsOtpExpiry
if (!storedOtp || Date.now() > expiry) {
return Response.json({ error: 'Code expired' }, { status: 400 })
}
if (storedOtp !== parseInt(code)) {
return Response.json({ error: 'Invalid code' }, { status: 400 })
}
// Clear OTP after successful verification
await clerkClient.users.updateUserMetadata(userId, {
privateMetadata: { smsOtp: null, smsOtpExpiry: null }
})
return Response.json({ success: true })
}
Supabase Auth
Configure custom SMS provider for Supabase authentication.Copy
// supabase/functions/send-sms/index.ts
Deno.serve(async (req) => {
const { phone, token, type } = await req.json()
// Customize message based on type
const messages = {
signup: `Welcome! Your verification code is ${token}`,
login: `Your login code is ${token}. Valid for 60 seconds.`,
recovery: `Your password reset code is ${token}`,
}
const response = await fetch('https://www.unosend.co/api/v1/sms', {
method: 'POST',
headers: {
'Authorization': `Bearer ${Deno.env.get('UNOSEND_API_KEY')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: phone,
body: messages[type] || `Your verification code is ${token}`,
metadata: { type, timestamp: Date.now() }
})
})
const data = await response.json()
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
})
})
- Go to Authentication → Phone
- Enable phone auth
- Select Custom SMS Provider
- Set Hook URL:
https://your-project.supabase.co/functions/v1/send-sms - Add
UNOSEND_API_KEYto Function Secrets
Firebase Auth
Send SMS verification codes with Firebase Cloud Functions.Copy
// functions/index.js
const functions = require('firebase-functions')
const admin = require('firebase-admin')
admin.initializeApp()
exports.sendSMSVerification = functions.https.onCall(async (data, context) => {
// Verify user is authenticated
if (!context.auth) {
throw new functions.https.HttpsError('unauthenticated', 'Must be logged in')
}
const { phone } = data
const uid = context.auth.uid
// Generate 6-digit code
const code = Math.floor(100000 + Math.random() * 900000)
// Store in Firestore
await admin.firestore().collection('smsVerifications').doc(uid).set({
code: code.toString(),
phone,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
expiresAt: new Date(Date.now() + 300000)
})
// Send via Unosend
const response = await fetch('https://www.unosend.co/api/v1/sms', {
method: 'POST',
headers: {
'Authorization': `Bearer ${functions.config().unosend.key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: phone,
body: `Your verification code is ${code}. Valid for 5 minutes.`,
metadata: { uid }
})
})
const result = await response.json()
return { success: true, messageId: result.data[0].id }
})
// Verify code
exports.verifySMSCode = functions.https.onCall(async (data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError('unauthenticated', 'Must be logged in')
}
const { code } = data
const uid = context.auth.uid
const doc = await admin.firestore().collection('smsVerifications').doc(uid).get()
if (!doc.exists) {
throw new functions.https.HttpsError('not-found', 'No verification code found')
}
const data = doc.data()
if (new Date() > data.expiresAt.toDate()) {
throw new functions.https.HttpsError('failed-precondition', 'Code expired')
}
if (data.code !== code.toString()) {
throw new functions.https.HttpsError('invalid-argument', 'Invalid code')
}
// Mark phone as verified
await admin.auth().updateUser(uid, {
phoneNumber: data.phone
})
// Delete verification doc
await doc.ref.delete()
return { success: true, verified: true }
})
Copy
import { getFunctions, httpsCallable } from 'firebase/functions'
const functions = getFunctions()
// Send code
const sendSMS = httpsCallable(functions, 'sendSMSVerification')
const result = await sendSMS({ phone: '+14155551234' })
// Verify code
const verifyCode = httpsCallable(functions, 'verifySMSCode')
await verifyCode({ code: '123456' })
Copy
firebase functions:config:set unosend.key="un_your_api_key"
Auth0
Add SMS OTP to Auth0 login flow with Actions.Copy
// Auth0 Action: Send SMS OTP
exports.onExecutePostLogin = async (event, api) => {
// Check if SMS 2FA is enabled for user
if (event.user.app_metadata.sms_2fa_enabled) {
const otp = Math.floor(100000 + Math.random() * 900000)
// Store OTP in user metadata
api.user.setAppMetadata('sms_otp', otp)
api.user.setAppMetadata('sms_otp_expiry', Date.now() + 300000)
try {
// Send via Unosend
const response = await fetch('https://www.unosend.co/api/v1/sms', {
method: 'POST',
headers: {
'Authorization': `Bearer ${event.secrets.UNOSEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: event.user.phone_number,
body: `Your Auth0 security code is ${otp}. Do not share.`,
metadata: { userId: event.user.user_id }
})
})
if (!response.ok) {
throw new Error('Failed to send SMS')
}
// Require OTP verification
api.multifactor.enable('sms', { message: 'Please verify with SMS code' })
} catch (error) {
console.error('SMS Error:', error)
api.access.deny('Failed to send verification code')
}
}
}
- Go to Actions → Library → Custom
- Create action with above code
- Add Secret:
UNOSEND_API_KEYwith your API key - Deploy and add to Login flow
Custom 2FA Implementation
Build your own 2FA system with Unosend.Copy
// app/api/auth/2fa/send/route.ts
import { db } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth'
export async function POST(req: Request) {
const user = await getCurrentUser()
if (!user?.phone) {
return Response.json({ error: 'No phone number' }, { status: 400 })
}
// Generate 6-digit code
const code = Math.floor(100000 + Math.random() * 900000).toString()
// Store in database with 5-minute expiry
await db.twoFactorToken.upsert({
where: { userId: user.id },
update: {
code,
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
attempts: 0
},
create: {
userId: user.id,
code,
expiresAt: new Date(Date.now() + 5 * 60 * 1000)
}
})
// Send via Unosend
const response = await fetch('https://www.unosend.co/api/v1/sms', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.UNOSEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: user.phone,
body: `Your 2FA code is ${code}. Don't share with anyone. Valid for 5 minutes.`,
metadata: { userId: user.id, type: '2fa' }
})
})
if (!response.ok) {
return Response.json({ error: 'Failed to send SMS' }, { status: 500 })
}
return Response.json({ success: true })
}
// app/api/auth/2fa/verify/route.ts
export async function POST(req: Request) {
const user = await getCurrentUser()
const { code } = await req.json()
const token = await db.twoFactorToken.findUnique({
where: { userId: user.id }
})
if (!token) {
return Response.json({ error: 'No code found' }, { status: 404 })
}
// Check expiry
if (new Date() > token.expiresAt) {
await db.twoFactorToken.delete({ where: { userId: user.id } })
return Response.json({ error: 'Code expired' }, { status: 400 })
}
// Check attempts (max 3)
if (token.attempts >= 3) {
await db.twoFactorToken.delete({ where: { userId: user.id } })
return Response.json({ error: 'Too many attempts' }, { status: 429 })
}
// Verify code
if (token.code !== code) {
await db.twoFactorToken.update({
where: { userId: user.id },
data: { attempts: token.attempts + 1 }
})
return Response.json({ error: 'Invalid code' }, { status: 400 })
}
// Success - delete token
await db.twoFactorToken.delete({ where: { userId: user.id } })
// Mark session as 2FA verified
await updateSession({ twoFactorVerified: true })
return Response.json({ success: true })
}
Magic Link Alternative
Replace email magic links with SMS codes.Copy
// app/api/auth/magic-sms/send/route.ts
export async function POST(req: Request) {
const { phone } = await req.json()
// Generate secure token
const token = crypto.randomBytes(32).toString('hex')
const code = Math.floor(100000 + Math.random() * 900000).toString()
// Store in database with 10-minute expiry
await db.magicLink.create({
data: {
phone,
token,
code,
expiresAt: new Date(Date.now() + 10 * 60 * 1000)
}
})
// Send via Unosend
await fetch('https://www.unosend.co/api/v1/sms', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.UNOSEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: phone,
body: `Your login code: ${code}\nOr click: ${process.env.APP_URL}/auth/verify/${token}`,
metadata: { type: 'magic-link' }
})
})
return Response.json({ success: true })
}
// app/api/auth/magic-sms/verify/route.ts
export async function POST(req: Request) {
const { code, phone } = await req.json()
const link = await db.magicLink.findFirst({
where: {
phone,
code,
expiresAt: { gt: new Date() }
}
})
if (!link) {
return Response.json({ error: 'Invalid or expired code' }, { status: 400 })
}
// Create/login user
let user = await db.user.findUnique({ where: { phone } })
if (!user) {
user = await db.user.create({ data: { phone } })
}
// Create session
await createSession(user.id)
// Delete used link
await db.magicLink.delete({ where: { id: link.id } })
return Response.json({ success: true, userId: user.id })
}
Common Use Cases
Phone Number Verification
Copy
// Verify new phone numbers
async function verifyPhoneNumber(userId, phone) {
const code = Math.floor(100000 + Math.random() * 900000)
await db.phoneVerification.create({
userId,
phone,
code: code.toString(),
expiresAt: new Date(Date.now() + 10 * 60 * 1000)
})
await fetch('https://www.unosend.co/api/v1/sms', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.UNOSEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: phone,
body: `Verify your phone number with code: ${code}`,
tags: ['verification']
})
})
}
Password Reset
Copy
// Send password reset code
async function sendPasswordResetSMS(phone) {
const code = Math.floor(100000 + Math.random() * 900000)
await db.passwordReset.create({
phone,
code: code.toString(),
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
})
await fetch('https://www.unosend.co/api/v1/sms', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.UNOSEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: phone,
body: `Your password reset code is ${code}. Valid for 15 minutes.`,
tags: ['password-reset']
})
})
}
Security Alerts
Copy
// Alert on suspicious activity
async function sendSecurityAlert(user, activity) {
await fetch('https://www.unosend.co/api/v1/sms', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.UNOSEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: user.phone,
body: `Security alert: ${activity}. If this wasn't you, secure your account immediately.`,
tags: ['security']
})
})
}
Best Practices
Rate Limiting
Prevent abuse with rate limits:Copy
const RATE_LIMITS = {
PER_PHONE_PER_HOUR: 3,
PER_PHONE_PER_DAY: 10,
}
async function checkRateLimit(phone) {
const hourAgo = new Date(Date.now() - 60 * 60 * 1000)
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
const [hourCount, dayCount] = await Promise.all([
db.smsLog.count({
where: { phone, createdAt: { gte: hourAgo } }
}),
db.smsLog.count({
where: { phone, createdAt: { gte: dayAgo } }
})
])
if (hourCount >= RATE_LIMITS.PER_PHONE_PER_HOUR) {
throw new Error('Too many SMS requests. Try again later.')
}
if (dayCount >= RATE_LIMITS.PER_PHONE_PER_DAY) {
throw new Error('Daily SMS limit reached.')
}
}
Secure Storage
Hash codes before storing:Copy
import { createHash } from 'crypto'
function hashCode(code) {
return createHash('sha256').update(code).digest('hex')
}
// Store hashed
await db.otp.create({
userId,
codeHash: hashCode(code),
expiresAt: new Date(Date.now() + 300000)
})
// Verify
const stored = await db.otp.findUnique({ where: { userId } })
if (hashCode(inputCode) === stored.codeHash) {
// Valid
}
Code Expiry
Always set expiry times:- OTP/2FA: 5 minutes
- Magic Links: 10 minutes
- Password Reset: 15 minutes
- Phone Verification: 10 minutes
Retry Logic
Allow code resend with cooldown:Copy
async function canResend(userId) {
const lastSent = await db.smsLog.findFirst({
where: { userId },
orderBy: { createdAt: 'desc' }
})
if (!lastSent) return true
const COOLDOWN = 60 * 1000 // 60 seconds
return Date.now() - lastSent.createdAt.getTime() > COOLDOWN
}
Error Handling
Handle SMS failures gracefully:Copy
try {
const response = await fetch('https://www.unosend.co/api/v1/sms', { ... })
if (!response.ok) {
const error = await response.json()
// Handle specific errors
if (response.status === 429) {
throw new Error('Rate limit exceeded. Try again later.')
}
if (response.status === 402) {
throw new Error('SMS quota exceeded. Upgrade plan.')
}
throw new Error(error.message || 'Failed to send SMS')
}
} catch (error) {
// Log error
console.error('SMS Error:', error)
// Fallback to email
await sendEmailFallback(user.email, code)
}
Environment Variables
Copy
# .env
UNOSEND_API_KEY=un_your_api_key
UNOSEND_API_URL=https://www.unosend.co/api/v1
# Rate limits
SMS_RATE_LIMIT_HOUR=3
SMS_RATE_LIMIT_DAY=10
# Code expiry (minutes)
OTP_EXPIRY_MINUTES=5
MAGIC_LINK_EXPIRY_MINUTES=10
PASSWORD_RESET_EXPIRY_MINUTES=15