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
- User enters their email address
- System sends an email with a unique link
- User clicks the link in their email
- 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
'use client';
import { useState } from 'react';
import { useForm } from '@tanstack/react-form';
import { Field, FieldLabel } from '@kit/ui/field';
import { Input } from '@kit/ui/input';
import { sendMagicLinkFunction } from '../_lib/functions';
export function MagicLinkForm() {
const [sent, setSent] = useState(false);
const form = useForm({
defaultValues: { email: '' },
onSubmit: async ({ value }) => {
const result = await sendMagicLinkFunction({ data: value });
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={(e) => {
e.preventDefault();
void form.handleSubmit();
}}
>
<form.Field name="email">
{(field) => (
<Field>
<FieldLabel>Email address</FieldLabel>
<Input
type="email"
placeholder="you@example.com"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</Field>
)}
</form.Field>
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => (
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send magic link'}
</button>
)}
</form.Subscribe>
</form>
);
}
Server Function
import { createServerFn } from '@tanstack/react-start';
import * as z from 'zod';
import { errorMiddleware } from '@kit/function-middleware/server';
export const sendMagicLinkFunction = createServerFn({ method: 'POST' })
.middleware([errorMiddleware])
.validator(z.object({ email: z.email() }))
.handler(async ({ data }) => {
const origin = process.env.VITE_SITE_URL!;
await sendMagicLink({
email: data.email,
redirectTo: `${origin}/auth/callback`,
createUser: true,
});
return {
success: true,
message: 'Check your email for the magic link',
};
});
Configuration
Enable Magic Links
Configure magic links in your auth configuration:
// config/auth.config.ts
export const authConfig = {
providers: {
emailLink: true,
},
};
Configure Email Template
Customize the magic link email 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:
// src/routes/auth/callback.ts
import { createFileRoute } from '@tanstack/react-router';
import { verifyMagicLink } from '@kit/auth/server';
export const Route = createFileRoute('/auth/callback')({
server: {
handlers: {
GET: async ({ request }) => {
const requestUrl = new URL(request.url);
const token = requestUrl.searchParams.get('token');
if (token) {
try {
await verifyMagicLink(token);
return Response.redirect(new URL('/home', request.url));
} catch (error) {
// Redirect back with an error if verification failed
return Response.redirect(
new URL('/auth/sign-in?error=invalid_link', request.url),
);
}
}
return Response.redirect(
new URL('/auth/sign-in?error=invalid_link', request.url),
);
},
},
},
});
Advanced Features
Custom Redirect
Specify where users go after clicking the link:
await sendMagicLink({
email: data.email,
redirectTo: `${origin}/onboarding`,
});
Disable Auto Sign-Up
Require users to sign up first:
await sendMagicLink({
email: data.email,
createUser: false, // Don't create new users
});
Token Expiry
Configure link expiration (default: 1 hour) in your auth configuration:
// config/auth.config.ts
export const authConfig = {
magicLink: {
expiresIn: '15 minutes',
},
};
Rate Limiting
Prevent abuse by rate limiting magic link requests:
import { createServerFn } from '@tanstack/react-start';
import { getRequestHeaders } from '@tanstack/react-start/server';
import { errorMiddleware } from '@kit/function-middleware/server';
import { ratelimit } from '~/lib/rate-limit';
export const sendMagicLinkFunction = createServerFn({ method: 'POST' })
.middleware([errorMiddleware])
.validator(EmailSchema)
.handler(async ({ data }) => {
// Rate limit by IP
const ip = getRequestHeaders().get('x-forwarded-for') || 'unknown';
const { success } = await ratelimit.limit(ip);
if (!success) {
throw new Error('Too many requests. Please try again later.');
}
await sendMagicLink({
email: data.email,
});
return { success: true };
});
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:
// Auth service handles this automatically // Each link can only be used once
Email Verification
Ensure emails are verified:
import { getSession } from '@kit/auth/server';
const session = await getSession();
if (!session?.user.emailVerified) {
throw redirect({ href: '/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 sendMagicLinkFunction(data);
setStatus('sent');
};
return (
<>
{status === 'idle' && <EmailForm onSubmit={onSubmit} />}
{status === 'sending' && <SendingMessage />}
{status === 'sent' && <CheckEmailMessage />}
</>
);
}
Resend Link
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 sendMagicLinkFunction({ 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:
- Add SPF record to DNS
- Enable DKIM signing
- Set up DMARC policy
Custom Email Domain
Use your own domain for better deliverability:
- Go to Project Settings → Auth
- Configure custom SMTP
- 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:54324
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
- Clear communication - Tell users to check spam
- Short expiry - 15-30 minutes for security
- Rate limiting - Prevent abuse
- Fallback option - Offer password auth as backup
- Custom domain - Better deliverability
- Monitor delivery - Track bounces and failures
- Resend option - Let users request new link
- Mobile-friendly - Ensure links work on mobile