--- title: "Magic Links" description: "Passwordless authentication with email magic links." publishedAt: 2024-04-11 order: 4 status: "published" --- > **Note:** This is mock/placeholder content for demonstration purposes. Magic links provide passwordless authentication by sending a one-time link to the user's email. ## How It Works 1. User enters their email address 2. System sends an email with a unique link 3. User clicks the link in their email 4. User is automatically signed in ## Benefits - **No password to remember** - Better UX - **More secure** - No password to steal - **Lower friction** - Faster sign-up process - **Email verification** - Confirms email ownership ## Implementation ### Magic Link Form ```tsx 'use client'; import { useForm } from 'react-hook-form'; import { sendMagicLinkAction } from '../_lib/actions'; export function MagicLinkForm() { const { register, handleSubmit, formState: { isSubmitting } } = useForm(); const [sent, setSent] = useState(false); const onSubmit = async (data) => { const result = await sendMagicLinkAction(data); if (result.success) { setSent(true); } }; if (sent) { return (

Check your email

We've sent you a magic link to sign in.

); } return (
); } ``` ### Server Action ```typescript 'use server'; import { enhanceAction } from '@kit/next/actions'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import * as z from 'zod'; export const sendMagicLinkAction = enhanceAction( async (data) => { const client = getSupabaseServerClient(); const origin = process.env.NEXT_PUBLIC_SITE_URL!; const { error } = await client.auth.signInWithOtp({ email: data.email, options: { emailRedirectTo: `${origin}/auth/callback`, shouldCreateUser: true, }, }); if (error) throw error; return { success: true, message: 'Check your email for the magic link', }; }, { schema: z.object({ email: z.string().email(), }), } ); ``` ## Configuration ### Enable in Supabase 1. Go to **Authentication** → **Providers** → **Email** 2. Enable "Enable Email Provider" 3. Enable "Enable Email Confirmations" ### Configure Email Template Customize the magic link email in Supabase Dashboard: 1. Go to **Authentication** → **Email Templates** 2. Select "Magic Link" 3. Customize the template: ```html

Sign in to {{ .SiteURL }}

Click the link below to sign in:

Sign in

This link expires in {{ .TokenExpiryHours }} hours.

``` ## Callback Handler Handle the magic link callback: ```typescript // app/auth/callback/route.ts import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; import { cookies } from 'next/headers'; import { NextResponse } from 'next/server'; export async function GET(request: Request) { const requestUrl = new URL(request.url); const token_hash = requestUrl.searchParams.get('token_hash'); const type = requestUrl.searchParams.get('type'); if (token_hash && type === 'magiclink') { const cookieStore = cookies(); const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); const { error } = await supabase.auth.verifyOtp({ token_hash, type: 'magiclink', }); if (!error) { return NextResponse.redirect(new URL('/home', request.url)); } } // Return error if verification failed return NextResponse.redirect( new URL('/auth/sign-in?error=invalid_link', request.url) ); } ``` ## Advanced Features ### Custom Redirect Specify where users go after clicking the link: ```typescript await client.auth.signInWithOtp({ email: data.email, options: { emailRedirectTo: `${origin}/onboarding`, }, }); ``` ### Disable Auto Sign-Up Require users to sign up first: ```typescript await client.auth.signInWithOtp({ email: data.email, options: { shouldCreateUser: false, // Don't create new users }, }); ``` ### Token Expiry Configure link expiration (default: 1 hour): ```sql -- In Supabase SQL Editor ALTER TABLE auth.users SET default_token_lifetime = '15 minutes'; ``` ## Rate Limiting Prevent abuse by rate limiting magic link requests: ```typescript import { ratelimit } from '~/lib/rate-limit'; export const sendMagicLinkAction = enhanceAction( async (data, user, request) => { // Rate limit by IP const ip = request.headers.get('x-forwarded-for') || 'unknown'; const { success } = await ratelimit.limit(ip); if (!success) { throw new Error('Too many requests. Please try again later.'); } const client = getSupabaseServerClient(); await client.auth.signInWithOtp({ email: data.email, }); return { success: true }; }, { schema: EmailSchema } ); ``` ## Security Considerations ### Link Expiration Magic links should expire quickly: - Default: 1 hour - Recommended: 15-30 minutes for production - Shorter for sensitive actions ### One-Time Use Links should be invalidated after use: ```typescript // Supabase handles this automatically // Each link can only be used once ``` ### Email Verification Ensure emails are verified: ```typescript const { data: { user } } = await client.auth.getUser(); if (!user.email_confirmed_at) { redirect('/verify-email'); } ``` ## User Experience ### Loading State Show feedback while sending: ```tsx export function MagicLinkForm() { const [status, setStatus] = useState<'idle' | 'sending' | 'sent'>('idle'); const onSubmit = async (data) => { setStatus('sending'); await sendMagicLinkAction(data); setStatus('sent'); }; return ( <> {status === 'idle' && } {status === 'sending' && } {status === 'sent' && } ); } ``` ### Resend Link Allow users to request a new link: ```tsx export function ResendMagicLink({ email }: { email: string }) { const [canResend, setCanResend] = useState(false); const [countdown, setCountdown] = useState(60); useEffect(() => { if (countdown > 0) { const timer = setTimeout(() => setCountdown(countdown - 1), 1000); return () => clearTimeout(timer); } else { setCanResend(true); } }, [countdown]); const handleResend = async () => { await sendMagicLinkAction({ email }); setCountdown(60); setCanResend(false); }; return ( ); } ``` ## Email Deliverability ### SPF, DKIM, DMARC Configure email authentication: 1. Add SPF record to DNS 2. Enable DKIM signing 3. Set up DMARC policy ### Custom Email Domain Use your own domain for better deliverability: 1. Go to **Project Settings** → **Auth** 2. Configure custom SMTP 3. Verify domain ownership ### Monitor Bounces Track email delivery issues: ```typescript // Handle email bounces export async function handleEmailBounce(email: string) { await client.from('email_bounces').insert({ email, bounced_at: new Date(), }); // Notify user via other channel } ``` ## Testing ### Local Development In development, emails go to InBucket: ``` http://localhost:54324 ``` Check this URL to see magic link emails during testing. ### Test Mode Create a test link without sending email: ```typescript if (process.env.NODE_ENV === 'development') { console.log('Magic link URL:', confirmationUrl); } ``` ## Best Practices 1. **Clear communication** - Tell users to check spam 2. **Short expiry** - 15-30 minutes for security 3. **Rate limiting** - Prevent abuse 4. **Fallback option** - Offer password auth as backup 5. **Custom domain** - Better deliverability 6. **Monitor delivery** - Track bounces and failures 7. **Resend option** - Let users request new link 8. **Mobile-friendly** - Ensure links work on mobile