Magic Links

Passwordless authentication with email magic links.

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

'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 (
<div className="text-center">
  <h2>Check your email</h2>
  <p>We've sent you a magic link to sign in.</p>
</div>
);
}

return (
<form onSubmit={handleSubmit(onSubmit)}>
  <div>
    <label>Email address</label>
    <input type="email" {...register('email', { required: true })} placeholder="you@example.com" />
  </div>

  <button type="submit" disabled={isSubmitting}>
    {isSubmitting ? 'Sending...' : 'Send magic link'}
  </button>
</form>
);
}

Server Action

'use server';

import { enhanceAction } from '@kit/next/actions';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { 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 AuthenticationProvidersEmail
  2. Enable "Enable Email Provider"
  3. Enable "Enable Email Confirmations"

Configure Email Template

Customize the magic link email in Supabase Dashboard:

  1. Go to AuthenticationEmail Templates
  2. Select "Magic Link"
  3. Customize the template:
<h2>Sign in to {{ .SiteURL }}</h2>
<p>Click the link below to sign in:</p>
<p><a href="{{ .ConfirmationURL }}">Sign in</a></p>
<p>This link expires in {{ .TokenExpiryHours }} hours.</p>

Callback Handler

Handle the magic link callback:

// 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:

await client.auth.signInWithOtp({
email: data.email,
options: {
emailRedirectTo: `${origin}/onboarding`,
},
});

Disable Auto Sign-Up

Require users to sign up first:

await client.auth.signInWithOtp({
email: data.email,
options: {
shouldCreateUser: false, // Don't create new users
},
});

Token Expiry

Configure link expiration (default: 1 hour):

-- In Supabase SQL Editor
ALTER TABLE auth.users
SET default_token_lifetime = '15 minutes';

Rate Limiting

Prevent abuse by rate limiting magic link requests:

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

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:

// Supabase handles this automatically
// Each link can only be used once

Email Verification

Ensure emails are verified:

const { data: { user } } = await client.auth.getUser();

if (!user.email_confirmed_at) {
redirect('/verify-email');
}

User Experience

Loading State

Show feedback while sending:

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' &&
    <EmailForm onSubmit={onSubmit} />}
    {status === 'sending' &&
    <SendingMessage />}
    {status === 'sent' &&
    <CheckEmailMessage />}
  </>
  );
  }

Allow users to request a new link:

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 (
<button onClick={handleResend} disabled={!canResend}>
  {canResend ? 'Resend link' : `Resend in ${countdown}s`}
</button>
);
}

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 SettingsAuth
  2. Configure custom SMTP
  3. Verify domain ownership

Monitor Bounces

Track email delivery issues:

// 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:54334

Check this URL to see magic link emails during testing.

Test Mode

Create a test link without sending email:

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