Skip to main content

How to Implement Magic Link Auth in 2026

·APIScout Team
Share:

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.).

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';
// }
// 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

MeasureWhyImplementation
Short expiry (15 min)Limits attack windowsetExpirationTime('15m')
One-time usePrevents replay attacksStore used token IDs
Cryptographic randomnessPrevents guessingcrypto.randomBytes()
HTTPS onlyPrevents interceptionsecure: true on cookies
Rate limitingPrevents email bombing5 per email per 15 min
Don't reveal user existencePrevents enumerationAlways 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
}
MethodUXSecuritySetup Complexity
Magic LinksGood (email required)HighLow
Passkeys/WebAuthnExcellent (biometric)Very HighMedium
SMS OTPGood (phone required)Medium (SIM swap risk)Medium
Email OTPGoodHighLow
Social LoginExcellent (one click)Depends on providerLow

Common Mistakes

MistakeImpactFix
Long token expiry (24h+)Wider attack windowKeep to 15 minutes max
Reusable tokensReplay attacks possibleTrack used token IDs, enforce one-time use
Token in URL query params logged by analyticsToken leaked to third partiesUse POST-based verification or strip params
No rate limiting on login endpointEmail bombing, abuseRate limit by email and IP
Revealing "email not found"User enumerationAlways 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).

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.

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

The API Integration Checklist (Free PDF)

Step-by-step checklist: auth setup, rate limit handling, error codes, SDK evaluation, and pricing comparison for 50+ APIs. Used by 200+ developers.

Join 200+ developers. Unsubscribe in one click.