Complete example of implementing passwordless authentication with email magic links.
This example demonstrates a complete passwordless authentication system using email magic links. Users receive a one-time link in their email to sign in without passwords.
Configure your email provider credentials:
# Authentication
AUTH_SECRET=your-secret-key-min-32-chars-long
# SMTP Configuration (Gmail example)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM=noreply@yourdomain.com
# Or use Resend
RESEND_API_KEY=re_your_resend_api_key
# Application URLs
APP_URL=http://localhost:3000Configure the email provider with Nodemailer (SMTP):
Email provider configuration
import { email } from '@warpy-auth-sdk/core';
export const emailProvider = email({
server: `${process.env.SMTP_HOST}:${process.env.SMTP_PORT}`,
from: process.env.SMTP_FROM!,
auth: {
user: process.env.SMTP_USER!,
pass: process.env.SMTP_PASS!,
},
// Optional: Custom token expiration (default: 15m)
tokenExpiration: '15m',
});
// Or use Resend
import { email } from '@warpy-auth-sdk/core';
export const emailProviderResend = email({
provider: 'resend',
apiKey: process.env.RESEND_API_KEY!,
from: process.env.SMTP_FROM!,
});Create an API endpoint to send magic links:
Magic link API endpoint
import { authenticate } from '@warpy-auth-sdk/core';
import { emailProvider } from '@/lib/email-provider';
import { NextRequest } from 'next/server';
export const runtime = 'nodejs'; // Required for Nodemailer
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email: userEmail } = body;
if (!userEmail || !userEmail.includes('@')) {
return Response.json(
{ error: 'Valid email is required' },
{ status: 400 }
);
}
// Send magic link
const result = await authenticate(
{
secret: process.env.AUTH_SECRET!,
provider: emailProvider,
callbacks: {
async user(user) {
// Optional: Check if user exists in your database
// const dbUser = await db.user.findUnique({
// where: { email: user.email },
// });
// if (!dbUser) {
// // Create new user
// await db.user.create({
// data: { email: user.email, name: user.name },
// });
// }
return {
id: user.email, // Use email as ID or your DB user ID
email: user.email,
name: user.name || user.email.split('@')[0],
};
},
},
},
request,
{ email: userEmail }
);
if (result.error) {
return Response.json(
{ error: result.error },
{ status: 400 }
);
}
return Response.json({
success: true,
message: 'Check your email for the magic link!',
});
} catch (error) {
console.error('Magic link error:', error);
return Response.json(
{ error: 'Failed to send magic link. Please try again.' },
{ status: 500 }
);
}
}Create a user-friendly login page for magic link authentication:
Magic link login page
'use client';
import { useAuth } from "@warpy-auth-sdk/core/hooks";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
export default function LoginPage() {
const { session, loading } = useAuth();
const router = useRouter();
const [email, setEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState<{
type: 'success' | 'error';
text: string;
} | null>(null);
useEffect(() => {
if (session) {
router.push("/dashboard");
}
}, [session, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setMessage(null);
try {
const response = await fetch('/api/auth/signin/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await response.json();
if (response.ok) {
setMessage({
type: 'success',
text: 'Check your email for the magic link! It will expire in 15 minutes.',
});
setEmail(''); // Clear email field
} else {
setMessage({
type: 'error',
text: data.error || 'Failed to send magic link. Please try again.',
});
}
} catch (error) {
setMessage({
type: 'error',
text: 'An error occurred. Please try again.',
});
} finally {
setIsSubmitting(false);
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (session) {
return null; // Will redirect to dashboard
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full">
<div className="bg-white rounded-2xl shadow-xl p-8">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900">
Welcome Back
</h2>
<p className="mt-2 text-sm text-gray-600">
Enter your email to receive a magic link
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email address
</label>
<input
id="email"
name="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isSubmitting}
className="appearance-none relative block w-full px-4 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="you@example.com"
/>
</div>
{message && (
<div
className={`rounded-lg p-4 ${
message.type === 'success'
? 'bg-green-50 border border-green-200'
: 'bg-red-50 border border-red-200'
}`}
>
<div className="flex">
<div className="flex-shrink-0">
{message.type === 'success' ? (
<svg className="h-5 w-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
) : (
<svg className="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
)}
</div>
<div className="ml-3">
<p className={`text-sm ${
message.type === 'success' ? 'text-green-800' : 'text-red-800'
}`}>
{message.text}
</p>
</div>
</div>
</div>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full flex justify-center items-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Sending magic link...
</>
) : (
'Send Magic Link'
)}
</button>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">
No password required
</span>
</div>
</div>
</div>
</div>
<p className="mt-4 text-center text-xs text-gray-600">
By signing in, you agree to our Terms of Service and Privacy Policy.
</p>
</div>
</div>
);
}Implement magic links with Express:
Complete Express server implementation
import express from "express";
import cookieParser from "cookie-parser";
import { authenticate, email, getSession } from "@warpy-auth-sdk/core";
const app = express();
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
// Email provider configuration
const emailProvider = email({
server: `${process.env.SMTP_HOST}:${process.env.SMTP_PORT}`,
from: process.env.SMTP_FROM!,
auth: {
user: process.env.SMTP_USER!,
pass: process.env.SMTP_PASS!,
},
});
// Magic link sign-in endpoint
app.post("/api/auth/signin/email", async (req, res) => {
try {
const { email: userEmail } = req.body;
if (!userEmail) {
return res.status(400).json({ error: "Email is required" });
}
const result = await authenticate(
{
secret: process.env.AUTH_SECRET!,
provider: emailProvider,
callbacks: {
async user(user) {
return {
id: user.email,
email: user.email,
name: user.name || user.email.split('@')[0],
};
},
},
},
req,
{ email: userEmail }
);
if (result.error) {
return res.status(400).json({ error: result.error });
}
res.json({
success: true,
message: "Magic link sent successfully",
});
} catch (error) {
console.error("Magic link error:", error);
res.status(500).json({ error: "Failed to send magic link" });
}
});
// Session endpoint
app.get("/api/auth/session", async (req, res) => {
const session = await getSession(req, process.env.AUTH_SECRET!);
res.json({ session });
});
app.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});Customize the magic link email template using React Email:
Branded magic link email template
import {
Html,
Head,
Body,
Container,
Section,
Text,
Button,
Heading,
} from '@react-email/components';
interface MagicLinkEmailProps {
magicLink: string;
userEmail: string;
}
export const MagicLinkEmail = ({ magicLink, userEmail }: MagicLinkEmailProps) => {
return (
<Html>
<Head />
<Body style={main}>
<Container style={container}>
<Section style={box}>
<Heading style={h1}>Welcome Back!</Heading>
<Text style={text}>
You requested a magic link to sign in to your account.
Click the button below to securely sign in:
</Text>
<Button style={button} href={magicLink}>
Sign In to Your Account
</Button>
<Text style={text}>
Or copy and paste this URL into your browser:
</Text>
<Text style={link}>{magicLink}</Text>
<Text style={footer}>
This link will expire in 15 minutes for security reasons.
If you didn't request this email, you can safely ignore it.
</Text>
<Text style={footer}>
Sent to: {userEmail}
</Text>
</Section>
</Container>
</Body>
</Html>
);
};
const main = {
backgroundColor: '#f6f9fc',
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};
const container = {
backgroundColor: '#ffffff',
margin: '0 auto',
padding: '20px 0 48px',
marginBottom: '64px',
};
const box = {
padding: '0 48px',
};
const h1 = {
color: '#333',
fontSize: '24px',
fontWeight: 'bold',
margin: '40px 0',
padding: '0',
};
const text = {
color: '#333',
fontSize: '16px',
lineHeight: '24px',
margin: '16px 0',
};
const button = {
backgroundColor: '#3b82f6',
borderRadius: '8px',
color: '#fff',
fontSize: '16px',
fontWeight: 'bold',
textDecoration: 'none',
textAlign: 'center' as const,
display: 'block',
padding: '12px 24px',
margin: '24px 0',
};
const link = {
color: '#3b82f6',
fontSize: '14px',
textDecoration: 'underline',
wordBreak: 'break-all' as const,
};
const footer = {
color: '#8898aa',
fontSize: '12px',
lineHeight: '16px',
margin: '16px 0',
};Follow these steps to test your magic link implementation:
npm run devcurl -X POST http://localhost:3000/api/auth/signin/email \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com"}'Open your email inbox and look for the magic link email. Check spam folder if needed.
Click the link in your email. You should be:
curl http://localhost:3000/api/auth/session \
-H "Cookie: auth-token=YOUR_SESSION_COOKIE"sequenceDiagram
participant User
participant App
participant SDK
participant Email
User->>App: Enter email address
App->>SDK: POST /api/auth/signin/email
SDK->>SDK: Generate secure token
SDK->>Email: Send magic link email
Email->>User: Deliver email
User->>User: Click magic link in email
User->>SDK: GET /api/auth/callback?token=...
SDK->>SDK: Validate token
SDK->>SDK: Create session & JWT
SDK->>App: Set session cookie
SDK->>User: Redirect to dashboard
App->>User: Show authenticated contentRecommendations for production:
Switch from Gmail to a dedicated email service:
Configure SPF, DKIM, and DMARC records for your domain to improve deliverability and prevent emails from going to spam.
Prevent magic link abuse with rate limiting
import rateLimit from 'express-rate-limit';
const magicLinkLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 3, // Max 3 requests per window
message: 'Too many magic link requests. Please try again later.',
standardHeaders: true,
legacyHeaders: false,
});
app.post("/api/auth/signin/email", magicLinkLimiter, async (req, res) => {
// Magic link logic...
});Enhance your authentication system:
Check SMTP credentials and ensure your email provider allows SMTP access. For Gmail, enable 2FA and create an App Password.
Increase the token expiration time in your email provider configuration:
const emailProvider = email({
// ... other config
tokenExpiration: '30m', // Increase to 30 minutes
});Set up SPF, DKIM, and DMARC records for your domain. Use a professional email service with good sender reputation.