How to Implement Magic Link Auth in 2026
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" |
Build vs Buy: When to Use a Provider
The from-scratch implementation above gives you full control, but auth is a reliability-critical system. A bug in your token validation or session management has immediate security consequences. Before implementing magic links from scratch, evaluate whether a managed auth provider fits your needs.
Use a provider (Clerk, Auth0, Supabase) when:
- You're a small team and auth isn't your core differentiator
- You need social login, SAML SSO, or MFA alongside magic links
- Compliance requirements (SOC 2, HIPAA) make managed auth easier to certify
- You want a hosted user dashboard out of the box
Build from scratch when:
- Your auth flow has unusual requirements (custom token formats, non-standard session lifetimes, hardware security module integration)
- You're handling very high volume where per-user pricing becomes expensive
- You need full data sovereignty (all tokens stored in your own infrastructure)
- You want to avoid vendor dependency in a core system
For most SaaS products in 2026, a managed provider for the first 6-18 months makes economic sense. The code to implement magic links correctly — including token storage, session management, email delivery, rate limiting, and audit logging — takes 2-3 weeks of engineering time. Clerk's magic link is included in its free tier (up to 10K monthly active users). Auth0's passwordless is available on all plans. The build-vs-buy break-even for auth is typically at 100K+ MAUs where per-user pricing adds up, or when your custom requirements genuinely can't be met by existing providers.
If you do build from scratch, the implementation in this guide is a solid foundation. The main things to add for production: proper database-backed token storage (replace the in-memory Set with a database table with an index on jti), an audit log table recording every login attempt (email, IP, timestamp, success/failure), and a mechanism to invalidate all sessions for a user (useful for "log out everywhere" and account security incidents).
Passkeys vs Magic Links: The Next Step
Magic links solve the password problem, but they still require email access at login time. Passkeys (WebAuthn) go further — they store a cryptographic key on the user's device and authenticate with biometrics or device PIN. No email required. The UX is "face ID to log in" rather than "check your email."
The trade-off is device-binding. A magic link works on any device that can receive email. A passkey is tied to a specific device (or synced across devices via Apple Keychain or Google Password Manager, but that sync requires an account). For users who frequently switch devices or use shared computers, magic links are more practical.
In 2026, passkeys have good enough browser support (Chrome 108+, Safari 16+, Firefox 122+) and platform sync that they're worth implementing alongside magic links. Offer both: passkey for users on their primary device, magic link as the fallback. Clerk, Auth0, and Hanko all support passkeys out of the box. For custom implementations, the @simplewebauthn/server and @simplewebauthn/browser packages handle the WebAuthn ceremony.
The good news: the user experience research is clear. Magic links convert better than passwords (fewer abandoned signups, fewer locked-out users), and passkeys convert even better than magic links for returning users. The optimal auth flow for most SaaS apps in 2026 is: first visit → magic link; return visit on same device → passkey prompt; fallback → magic link.
Email Deliverability for Magic Links
The fatal failure mode of magic link auth is the email not arriving. If users don't receive the link, they can't log in — and they'll think your product is broken. Deliverability is not optional.
Domain authentication is essential. Set up SPF, DKIM, and DMARC records on the domain you're sending magic links from. Without DKIM, Gmail and Outlook will increasingly route your login emails to spam. The specific records depend on your email provider (Resend, SendGrid, Postmark each have their own DKIM keys), but all three require adding DNS records and verifying the domain before production sends.
Use a dedicated "login" subdomain. Sending from login@yourdomain.com or noreply@yourdomain.com on a subdomain (email.yourdomain.com) gives you a separate IP reputation from your marketing emails. A spam complaint on a marketing campaign won't tank your transactional email deliverability.
Subject line matters. "Your login link" performs better in deliverability testing than "Sign in to [AppName]" — less likely to trigger spam filters because it's clearly transactional. Avoid words like "Verify", "Confirm", or "Activate" in the subject if you've had deliverability issues; these are common phishing email subjects and can trigger aggressive filtering.
Test across clients. Gmail, Outlook, and Apple Mail have different spam filter behaviors. Mail-tester.com gives you a free deliverability score. Before going live, send test magic links from your production domain to accounts at Gmail, Outlook, Yahoo, and Apple Mail and verify delivery time and placement (inbox vs spam). Aim for under 5 seconds delivery time — users will retry or contact support if the email takes more than 30 seconds.
Methodology
The magic link approach in this guide targets Next.js 15 App Router with the Resend email SDK, but the core patterns (JWT token generation with jose, one-time-use enforcement, rate limiting) are framework-agnostic and work with Express, Fastify, or any Node.js runtime. The JWT-based token approach uses the jose library (v5.x, the modern JOSE standard for Node.js) rather than jsonwebtoken, which lacks native ESM support and has a larger attack surface. The 15-minute token expiry is the industry standard for magic links — Clerk, Auth0, and Supabase all default to 15 minutes. The Redis one-time-use pattern (SET NX EX) is an atomic operation that prevents race conditions in distributed systems. The rate limit of 5 per email per 15 minutes matches the token validity window, preventing users from flooding their own inbox. Browser support data for passkeys is sourced from Can I Use (caniuse.com). Resend v4.x is the current SDK version. The passkey conversion data (magic links outperform passwords, passkeys outperform magic links for returning users) is drawn from auth provider case studies published by Clerk, Auth0, and 1Password; results vary by user demographic and product type. Measure your own conversion funnel before making auth architecture decisions.
Choosing an auth method? Compare Auth0 vs Clerk vs Firebase Auth on APIScout — passwordless support, pricing, and developer experience.
Related: How to Add Firebase Auth to a React App, How to Implement OAuth 2.0 with Auth0, Set Up Clerk Authentication in Next.js 2026