Skip to content

Magic Link Login

A magic link is a one-time URL emailed to the user. Clicking it logs them in — no password, no second device. The link itself is a signed token; ideal-auth’s createTokenVerifier handles the crypto, and the route handler hands off to auth().login(user) after verification.

This guide covers the SP-initiated flow: user enters email → server emails a link → user clicks → session is issued.


lib/auth.ts
import {
createAuth,
createTokenVerifier,
createRateLimiter,
} from 'ideal-auth';
export const auth = createAuth<User>({
secret: process.env.IDEAL_AUTH_SECRET!,
cookie: cookieBridge,
resolveUser: async (id) => db.user.findUnique({ where: { id } }),
});
export const magicLinks = createTokenVerifier({
secret: process.env.IDEAL_AUTH_SECRET!,
expiryMs: 15 * 60 * 1000, // 15 minutes
});
export const magicLinkLimiter = createRateLimiter({
maxAttempts: 5,
windowMs: 15 * 60 * 1000,
});

Magic links rely on a per-user timestamp to enforce single-use. Same column doubles as the post-password-change session-invalidation column from the password reset guide.

ALTER TABLE users ADD COLUMN last_login_at TIMESTAMPTZ;

app/auth/magic/request/route.ts
import { magicLinks, magicLinkLimiter } from '@/lib/auth';
import { db } from '@/lib/db';
import { sendEmail } from '@/lib/mail';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const { email } = await req.json();
const ip = req.headers.get('x-forwarded-for') ?? '127.0.0.1';
const ipLimit = await magicLinkLimiter.attempt(`magic:ip:${ip}`);
if (!ipLimit.allowed) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
}
const emailLimit = await magicLinkLimiter.attempt(
`magic:email:${email.toLowerCase()}`,
);
// Always respond identically — don't leak whether the email exists
const genericResponse = NextResponse.json({
message: 'If that email exists, a sign-in link was sent.',
});
if (!emailLimit.allowed) return genericResponse;
const user = await db.user.findUnique({
where: { email: email.toLowerCase() },
});
if (!user) return genericResponse;
const token = magicLinks.createToken(String(user.id));
const url = `${process.env.APP_URL}/auth/magic/callback/${token}`;
await sendEmail({
to: user.email,
subject: 'Sign in to Example',
text: `Click here to sign in: ${url}\n\nThis link expires in 15 minutes.`,
});
return genericResponse;
}

The user clicks the email link and lands on a route that verifies the token and issues the session in one step.

app/auth/magic/callback/[token]/route.ts
import { auth, magicLinks } from '@/lib/auth';
import { db } from '@/lib/db';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
req: NextRequest,
{ params }: { params: { token: string } },
) {
const result = magicLinks.verifyToken(params.token);
if (!result) {
return NextResponse.redirect(new URL('/sign-in?error=expired', req.url));
}
const { userId, iatMs } = result;
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) {
return NextResponse.redirect(new URL('/sign-in?error=expired', req.url));
}
// Single-use: reject tokens issued before the last successful login
if (user.lastLoginAt && user.lastLoginAt.getTime() >= iatMs) {
return NextResponse.redirect(new URL('/sign-in?error=used', req.url));
}
await db.user.update({
where: { id: user.id },
data: {
lastLoginAt: new Date(),
...(user.emailVerifiedAt ? {} : { emailVerifiedAt: new Date() }),
},
});
await auth().login(user);
return NextResponse.redirect(new URL('/dashboard', req.url));
}

The handoff to ideal-auth is one line: await auth().login(user). Everything before it is link verification; everything after it is your normal authenticated app.


The lastLoginAt check makes each link consumable exactly once without a separate token blocklist:

  1. Token created at time T → carries iatMs = T.
  2. On successful login, lastLoginAt is set to now (≥ T).
  3. If the same link is clicked again, iatMs is still T but lastLoginAt ≥ T → rejected.

This is the same trick the password reset guide uses with passwordChangedAt. If you already have that column, you can reuse it — but a dedicated lastLoginAt is cleaner because magic-link consumption shouldn’t bump the password-change timestamp.


If you want “sign in or sign up” with one button, create the user on first request:

let user = await db.user.findUnique({ where: { email: email.toLowerCase() } });
if (!user) {
user = await db.user.create({
data: { email: email.toLowerCase(), emailVerifiedAt: null },
});
}

The user’s email gets verified when they actually click the link (the callback sets emailVerifiedAt). Until then, the row exists but is unverified — which is fine because nothing else trusts it yet.


Magic links assume the user clicks the link on the same browser they entered the email in. That’s not always true:

  • Server-rendered apps: The session cookie issued in the callback is fine — the redirect carries it.
  • Mobile email apps: Tapping the link opens the system browser, which may not be the browser the user requested from. The session lands on the system browser; the original tab is still signed-out.
  • Webmail desktop: Usually fine; the link opens in the same browser.

If cross-device matters (the user enters their email on their laptop but clicks the link on their phone), reach for OTP instead — the code transfers cleanly between devices because the human types it.


Magic links die if the email doesn’t land. Things that help:

  • Use a transactional provider — Resend, Postmark, SendGrid. Don’t send from your app server’s SMTP.
  • Send from a domain you control with SPF + DKIM + DMARC.
  • Avoid “click here” as the only link text — spam filters notice. Show the URL too.
  • Set subject to something distinctive — generic subjects (“Sign in”) get caught more often than branded ones (“Sign in to Acme”).

  • Short expiry — 10–15 minutes.
  • Path segment, not query string/callback/TOKEN, not ?token=TOKEN.
  • Single-use via lastLoginAt — reject tokens with iatMs ≤ lastLoginAt.
  • Rate limit by IP and email — prevents enumeration and mailbox flooding.
  • Generic response — same JSON whether the email exists or not.
  • HTTPS only — magic links over HTTP leak the token to every hop.
  • Don’t log the URL — request logs containing the link give anyone with log access a live credential during its TTL.
  • Verify email on consumption — set emailVerifiedAt on first successful callback.

  • Cross-device flows are common. Use OTP — the user types a 6-digit code.
  • Users share inboxes (family Plex account, on-call rotation). The link logs in whoever clicks first.
  • Phishing risk is high (banks, admin consoles). Magic links train users to click “sign in” links in email. Use passkeys for high-value accounts.
  • You need step-up for sensitive actions. Magic links are good for “sign in”; they’re not a second factor. Layer them with 2FA if needed.