Magic Links y OTP en Supabase
Un magic link es un enlace único que se envia al email del usuario y lo autentica al hacer clic, sin necesitar contraseña. Un OTP (One-Time Password -- contraseña de un solo uso) es un código numérico que se envia por email o SMS y que el usuario ingresa manualmente.
Ambos métodos eliminan la fricción de las contraseñas. El usuario no tiene que recordar nada -- solo necesita acceso a su email o teléfono.
Cuando usar magic links vs contraseñas
| Criterio | Magic links / OTP | Email + password |
|---|---|---|
| Friccion de registro | Baja (sin contraseña) | Media (crear y recordar contraseña) |
| Seguridad | Alta (token de un solo uso) | Depende de la contraseña del usuario |
| Dependencia | Requiere acceso al email/teléfono | Solo necesita recordar credenciales |
| UX en login frecuente | Lento (esperar email cada vez) | Rápido (escribir contraseña) |
| Apps internas / admin | Excelente opción | También válido |
| Apps con login diario | No ideal como método único | Mejor experiencia |
La recomendación: ofrece magic links como opción adicional, no como único método. La combinación email+password con magic link como alternativa cubre la mayoría de casos.
Magic links por email
Enviar un magic link
import { createClient } from '@/lib/supabase/client'
const supabase = createClient()
const { data, error } = await supabase.auth.signInWithOtp({
email: 'usuario@ejemplo.com',
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`
}
})
if (error) {
console.error('Error:', error.message)
} else {
console.log('Magic link enviado. Revisa tu email.')
}Cuando el usuario hace clic en el link del email, Supabase lo redirige a la URL que especificaste en emailRedirectTo con un código de autorización. Tu ruta de callback (la misma que usamos para OAuth) procesa ese código y crea la sesión.
Mismo usuario, nueva cuenta
Si el email no está registrado, signInWithOtp crea automáticamente una cuenta nueva. Si ya existe, simplemente envia el link de login. No necesitas manejar registro y login por separado.
Desactivar registro automático
Si no quieres que signInWithOtp cree cuentas nuevas automáticamente:
const { data, error } = await supabase.auth.signInWithOtp({
email: 'usuario@ejemplo.com',
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
shouldCreateUser: false // No crea cuenta si no existe
}
})Con shouldCreateUser: false, si el email no está registrado, Supabase devuelve un error en vez de crear una cuenta nueva.
OTP por email (código numérico)
En vez de un link, puedes enviar un código de 6 dígitos que el usuario ingresa manualmente. Para esto, necesitas configurar el email template en el Dashboard de Supabase.
Configurar OTP por email
En el Dashboard: Authentication > Email Templates > Magic Link
Cambia el template para incluir el token como código en vez de link. Usa la variable {{ .Token }}:
<p>Tu código de verificación es: <strong>{{ .Token }}</strong></p>
<p>Este codigo expira en 1 hora.</p>Enviar y verificar OTP por email
// Paso 1: Enviar el codigo
const { data, error } = await supabase.auth.signInWithOtp({
email: 'usuario@ejemplo.com'
})
if (error) {
console.error('Error enviando codigo:', error.message)
}
// Paso 2: El usuario ingresa el codigo, y tu lo verificas
const { data: sessionData, error: verifyError } = await supabase.auth.verifyOtp({
email: 'usuario@ejemplo.com',
token: '123456', // El codigo que ingreso el usuario
type: 'email'
})
if (verifyError) {
console.error('Código inválido:', verifyError.message)
} else {
console.log('Autenticado:', sessionData.user)
}OTP por teléfono (SMS)
Supabase también soporta enviar códigos OTP por SMS. Para esto necesitas configurar un proveedor de SMS.
Configurar proveedor de SMS
En el Dashboard: Authentication > Providers > Phone
Supabase soporta estos proveedores de SMS:
- Twilio (el más común)
- MessageBird
- Vonage
- TextLocal
Necesitas una cuenta en uno de estos servicios. Twilio es el más popular y tiene un tier gratuito para pruebas.
Enviar y verificar OTP por SMS
// Paso 1: Enviar codigo por SMS
const { data, error } = await supabase.auth.signInWithOtp({
phone: '+521234567890' // Formato E.164 con codigo de pais
})
if (error) {
console.error('Error enviando SMS:', error.message)
}
// Paso 2: Verificar el codigo
const { data: sessionData, error: verifyError } = await supabase.auth.verifyOtp({
phone: '+521234567890',
token: '123456',
type: 'sms'
})
if (verifyError) {
console.error('Código inválido:', verifyError.message)
} else {
console.log('Autenticado:', sessionData.user)
}Formato de teléfono
El número de teléfono debe estar en formato E.164: código de pais + número, sin espacios ni guiones. Para Mexico: +52 seguido del número a 10 dígitos. Para Colombia: +57, Argentina: +54.
Parametros de verifyOtp
El método verifyOtp acepta diferentes type según el flujo:
| type | Uso |
|---|---|
email | Verificar OTP enviado por email |
sms | Verificar OTP enviado por SMS |
magiclink | Verificar token de magic link (raro, normalmente se maneja automáticamente) |
signup | Verificar email después del registro |
recovery | Verificar token de recuperación de contraseña |
email_change | Verificar cambio de email |
Componente completo de magic link
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
type Step = 'email' | 'code' | 'success'
export function MagicLinkForm() {
const [step, setStep] = useState<Step>('email')
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const supabase = createClient()
const handleSendCode = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setMessage('')
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`
}
})
if (error) {
setMessage('No se pudo enviar el codigo. Intenta de nuevo.')
} else {
setStep('code')
setMessage('Revisa tu email. Te enviamos un código de verificación.')
}
setLoading(false)
}
const handleVerifyCode = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setMessage('')
const { error } = await supabase.auth.verifyOtp({
email,
token: code,
type: 'email'
})
if (error) {
setMessage('Código inválido o expirado. Intenta de nuevo.')
} else {
setStep('success')
window.location.href = '/dashboard'
}
setLoading(false)
}
if (step === 'email') {
return (
<form onSubmit={handleSendCode} className="max-w-md mx-auto space-y-4">
<h2 className="text-xl font-bold">Iniciar sesión sin contrasena</h2>
<p className="text-gray-400 text-sm">
Te enviaremos un código de verificación a tu email.
</p>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="tu@email.com"
className="w-full px-3 py-2 border border-gray-600 rounded-md bg-transparent"
/>
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-green-600 text-white rounded-md
hover:bg-green-700 disabled:opacity-50"
>
{loading ? 'Enviando...' : 'Enviar codigo'}
</button>
{message && <p className="text-sm text-center">{message}</p>}
</form>
)
}
if (step === 'code') {
return (
<form onSubmit={handleVerifyCode} className="max-w-md mx-auto space-y-4">
<h2 className="text-xl font-bold">Ingresa el codigo</h2>
<p className="text-gray-400 text-sm">
Enviamos un codigo de 6 dígitos a {email}
</p>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
required
maxLength={6}
placeholder="123456"
className="w-full px-3 py-2 border border-gray-600 rounded-md
bg-transparent text-center text-2xl tracking-widest"
/>
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-green-600 text-white rounded-md
hover:bg-green-700 disabled:opacity-50"
>
{loading ? 'Verificando...' : 'Verificar codigo'}
</button>
<button
type="button"
onClick={() => { setStep('email'); setMessage('') }}
className="w-full text-sm text-gray-400 hover:text-white"
>
Usar otro email
</button>
{message && <p className="text-sm text-center">{message}</p>}
</form>
)
}
return <p>Redirigiendo...</p>
}Configuración del magic link en el Dashboard
Tiempo de expiración
En Authentication > Providers > Email:
- OTP Expiry: Tiempo en segundos antes de que el código expire (default: 3600 = 1 hora)
Para magic links, un tiempo más corto (15-30 minutos) es más seguro. Para OTP por email, 10-15 minutos es razonable.
Rate limiting
Supabase limita el envío de emails por defecto:
- Maximo 3 emails por hora por dirección de email
- Maximo 30 emails por hora por proyecto (en el free tier)
Si necesitas límites más altos, puedes configurar un proveedor de email externo (como Resend, SendGrid o Postmark) en Project Settings > Auth > SMTP Settings.
SMTP personalizado
Configurar tu propio SMTP no solo aumenta los límites, también permite que los emails salgan desde tu dominio (ejemplo: auth@tudominio.com) en vez de desde el dominio de Supabase. Esto mejora la entregabilidad y la confianza del usuario.
Magic links vs OTP: cuál elegir
- Magic link (link en el email): El usuario hace clic y queda autenticado. Más comodo, pero depende de que el email se abra en el mismo dispositivo/navegador dónde está tu app.
- OTP (código numérico): El usuario copia el código manualmente. Funciona aunque el email se abra en otro dispositivo (ejemplo: abrir el email en el teléfono y escribir el código en la laptop).
Para la mejor experiencia, envias el magic link Y el código en el mismo email. Así el usuario elige como autenticarse.