How to Implement Passwordless Auth with Magic Links
·APIScout Team
passwordless authmagic linksauthenticationtutorialsecurity
How to Implement Passwordless Auth with Magic Links
Magic links eliminate passwords entirely. User enters email, clicks a link, they're in. No password to forget, no credential stuffing attacks, no bcrypt. This guide covers building magic link auth from scratch and using providers that handle it for you.
What You'll Build
- Email-based magic link login
- Secure token generation and validation
- Session management with JWTs
- Rate limiting and security measures
- Provider-based implementation (Resend + custom)
Prerequisites: Next.js 14+, a transactional email provider (Resend, SendGrid, etc.).
1. How Magic Links Work
User enters email → Server generates token → Email sent with link
→ User clicks link → Server validates token → Session created
The token is a one-time-use, time-limited credential embedded in a URL. Click it, get authenticated. Simple.
2. Build It from Scratch
Token Generation
// lib/magic-link.ts
import crypto from 'crypto';
import { SignJWT, jwtVerify } from 'jose';
const SECRET = new TextEncoder().encode(process.env.MAGIC_LINK_SECRET!);
const TOKEN_EXPIRY = '15m'; // 15 minutes to click
// Generate a magic link token
export async function createMagicToken(email: string): Promise<string> {
const token = await new SignJWT({
email,
nonce: crypto.randomBytes(16).toString('hex'),
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(TOKEN_EXPIRY)
.setJti(crypto.randomUUID()) // Unique token ID for one-time use
.sign(SECRET);
return token;
}
// Verify a magic link token
export async function verifyMagicToken(token: string): Promise<{
email: string;
jti: string;
} | null> {
try {
const { payload } = await jwtVerify(token, SECRET);
return {
email: payload.email as string,
jti: payload.jti as string,
};
} catch {
return null; // Expired, tampered, or invalid
}
}
Token Store (One-Time Use)
// lib/token-store.ts
// In production, use Redis or your database
const usedTokens = new Set<string>();
export function markTokenUsed(jti: string): boolean {
if (usedTokens.has(jti)) return false; // Already used
usedTokens.add(jti);
// Clean up after 30 minutes (tokens expire in 15)
setTimeout(() => usedTokens.delete(jti), 30 * 60 * 1000);
return true;
}
// Redis version (production):
// export async function markTokenUsed(jti: string): Promise<boolean> {
// const result = await redis.set(`magic:${jti}`, '1', 'NX', 'EX', 1800);
// return result === 'OK';
// }
Send Magic Link Email
// lib/send-magic-email.ts
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
const APP_URL = process.env.NEXT_PUBLIC_URL!;
export async function sendMagicLink(email: string, token: string) {
const magicUrl = `${APP_URL}/api/auth/verify?token=${token}`;
await resend.emails.send({
from: 'Your App <login@yourdomain.com>',
to: email,
subject: 'Your login link',
html: `
<h2>Log in to Your App</h2>
<p>Click the link below to log in. This link expires in 15 minutes.</p>
<a href="${magicUrl}" style="
display: inline-block;
padding: 12px 32px;
background: #2563eb;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: bold;
">Log In</a>
<p style="color: #666; font-size: 14px; margin-top: 16px;">
If you didn't request this, ignore this email.
</p>
<p style="color: #999; font-size: 12px;">
Or copy this URL: ${magicUrl}
</p>
`,
});
}
Login API Route
// app/api/auth/login/route.ts
import { NextResponse } from 'next/server';
import { createMagicToken } from '@/lib/magic-link';
import { sendMagicLink } from '@/lib/send-magic-email';
// Rate limiting (simple in-memory)
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
export async function POST(req: Request) {
const { email } = await req.json();
if (!email || !email.includes('@')) {
return NextResponse.json({ error: 'Valid email required' }, { status: 400 });
}
// Rate limit: 5 attempts per email per 15 minutes
const now = Date.now();
const attempts = loginAttempts.get(email);
if (attempts && attempts.resetAt > now && attempts.count >= 5) {
return NextResponse.json(
{ error: 'Too many attempts. Try again later.' },
{ status: 429 }
);
}
if (!attempts || attempts.resetAt <= now) {
loginAttempts.set(email, { count: 1, resetAt: now + 15 * 60 * 1000 });
} else {
attempts.count++;
}
const token = await createMagicToken(email);
await sendMagicLink(email, token);
// Always return success (don't reveal if email exists)
return NextResponse.json({ message: 'Check your email for a login link' });
}
Verify API Route
// app/api/auth/verify/route.ts
import { NextResponse } from 'next/server';
import { verifyMagicToken } from '@/lib/magic-link';
import { markTokenUsed } from '@/lib/token-store';
import { createSession } from '@/lib/session';
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const token = searchParams.get('token');
if (!token) {
return NextResponse.redirect(new URL('/login?error=missing_token', req.url));
}
// Verify token signature and expiry
const payload = await verifyMagicToken(token);
if (!payload) {
return NextResponse.redirect(new URL('/login?error=invalid_or_expired', req.url));
}
// Ensure one-time use
const isFirstUse = markTokenUsed(payload.jti);
if (!isFirstUse) {
return NextResponse.redirect(new URL('/login?error=already_used', req.url));
}
// Create or find user
const user = await findOrCreateUser(payload.email);
// Create session
const sessionToken = await createSession(user.id);
// Set cookie and redirect
const response = NextResponse.redirect(new URL('/dashboard', req.url));
response.cookies.set('session', sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/',
});
return response;
}
async function findOrCreateUser(email: string) {
// Check if user exists in your database
// If not, create a new user record
// Return the user object
return { id: 'user_id', email };
}
Session Management
// lib/session.ts
import { SignJWT, jwtVerify } from 'jose';
const SESSION_SECRET = new TextEncoder().encode(process.env.SESSION_SECRET!);
export async function createSession(userId: string): Promise<string> {
return new SignJWT({ userId })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('30d')
.sign(SESSION_SECRET);
}
export async function getSession(token: string): Promise<{ userId: string } | null> {
try {
const { payload } = await jwtVerify(token, SESSION_SECRET);
return { userId: payload.userId as string };
} catch {
return null;
}
}
Login Page
// app/login/page.tsx
'use client';
import { useState } from 'react';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
setSent(true);
setLoading(false);
};
if (sent) {
return (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<h2>Check your email</h2>
<p>We sent a login link to <strong>{email}</strong></p>
<p style={{ color: '#666' }}>
The link expires in 15 minutes. Check spam if you don't see it.
</p>
<button onClick={() => setSent(false)}>
Use a different email
</button>
</div>
);
}
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '60px auto' }}>
<h2>Log in</h2>
<p>Enter your email to receive a login link.</p>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
style={{ width: '100%', padding: '12px', fontSize: '16px' }}
/>
<button
type="submit"
disabled={loading}
style={{
width: '100%',
padding: '12px',
marginTop: '12px',
background: '#2563eb',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '16px',
cursor: loading ? 'wait' : 'pointer',
}}
>
{loading ? 'Sending...' : 'Send Login Link'}
</button>
</form>
);
}
3. Provider-Based (Faster Setup)
With Clerk
// Clerk handles magic links out of the box
import { ClerkProvider, SignIn } from '@clerk/nextjs';
// In your sign-in page:
<SignIn
appearance={{
elements: {
rootBox: { width: '100%' },
},
}}
// Magic link is enabled by default alongside password
/>
With Auth0
Auth0 Dashboard → Authentication → Passwordless
→ Enable "Email" → Configure email template
→ Set OTP/Magic Link mode
With Supabase
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
// Send magic link
const { error } = await supabase.auth.signInWithOtp({
email: 'user@example.com',
options: {
emailRedirectTo: 'https://yourapp.com/auth/callback',
},
});
// Handle callback (app/auth/callback/route.ts)
import { NextResponse } from 'next/server';
import { createServerClient } from '@supabase/ssr';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
if (code) {
const supabase = createServerClient(/* ... */);
await supabase.auth.exchangeCodeForSession(code);
}
return NextResponse.redirect(new URL('/dashboard', request.url));
}
4. Security Best Practices
Token Security Checklist
| Measure | Why | Implementation |
|---|---|---|
| Short expiry (15 min) | Limits attack window | setExpirationTime('15m') |
| One-time use | Prevents replay attacks | Store used token IDs |
| Cryptographic randomness | Prevents guessing | crypto.randomBytes() |
| HTTPS only | Prevents interception | secure: true on cookies |
| Rate limiting | Prevents email bombing | 5 per email per 15 min |
| Don't reveal user existence | Prevents enumeration | Always return "check email" |
Additional Security
// Bind token to IP (optional, can cause issues with VPNs)
const token = await new SignJWT({
email,
ip: req.headers.get('x-forwarded-for'),
})
// Verify IP matches on validation
if (payload.ip && payload.ip !== req.headers.get('x-forwarded-for')) {
return null; // IP mismatch
}
Magic Links vs Other Passwordless Methods
| Method | UX | Security | Setup Complexity |
|---|---|---|---|
| Magic Links | Good (email required) | High | Low |
| Passkeys/WebAuthn | Excellent (biometric) | Very High | Medium |
| SMS OTP | Good (phone required) | Medium (SIM swap risk) | Medium |
| Email OTP | Good | High | Low |
| Social Login | Excellent (one click) | Depends on provider | Low |
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
| Long token expiry (24h+) | Wider attack window | Keep to 15 minutes max |
| Reusable tokens | Replay attacks possible | Track used token IDs, enforce one-time use |
| Token in URL query params logged by analytics | Token leaked to third parties | Use POST-based verification or strip params |
| No rate limiting on login endpoint | Email bombing, abuse | Rate limit by email and IP |
| Revealing "email not found" | User enumeration | Always show "check your email" |
Choosing an auth method? Compare Auth0 vs Clerk vs Firebase Auth on APIScout — passwordless support, pricing, and developer experience.