Skip to content
Chapter 53Lesson 5

Magic links

Passwordless sign-in with Better Auth's magic link plugin, when it earns its place over a password and how to wire it.

You have done this without thinking: you type your email into Slack or Substack, and instead of a password field you get a quiet “We sent you a link.” You tap it, and you are in. Nothing typed, nothing remembered, nothing to reset months later.

That is a magic link, and it is not the right default for every product. This lesson covers the judgment of when passwordless earns its place versus when it taxes every sign-in, and the wiring, which you have almost entirely built already. By the end you can make the call and configure the magicLink() plugin, reusing every token primitive from the verification and reset flows.

Across the last four flows you settled on a default: email and password, gated by email verification, with two-factor authentication for accounts that need it. It works for almost every product in its first year, and magic links do not replace it. They are a tool you reach for when your product crosses a specific threshold.

Magic links win in three situations:

  • People sign in rarely. At a weekly or monthly cadence, the real credential risk is not a guessed password but a reused or forgotten one. Drop the password and that risk goes with it, and an occasional trip to the inbox is cheap.
  • A non-technical audience floods you with resets. If “I forgot my password” is a meaningful share of your support tickets, that category disappears when there is no password to forget.
  • Email is already where the product lives. Newsletter platforms, mailing-list tools, anything where the user spends the day in their inbox. The link meets them where they already are.

They lose in three:

  • People sign in constantly. On a dashboard someone opens every day, the inbox round-trip is friction on every session, and daily users come to resent it.
  • Delivery is shaky. Once sign-in depends on an email landing, a spam filter or a locked-down corporate mail server can lock a user out through no fault of their own. This is the single biggest risk magic links carry.
  • You have a hard second-factor requirement. When the product touches money or admin powers, a password gives you a clean first factor to anchor the second on. Without it, the design is harder to reason about near a money path.

Ask the questions in order: how often, then who, then how reliably email lands. Each answer moves you to the next and ends on a verdict.

Should this product offer magic links?

One mental model carries the rest of this lesson: the inbox is the credential. A magic-link click proves exactly one thing, that the person controls the email account, trading a remembered secret for a delivered one. From that, the rest follows: why the link expires fast, why deliverability is the dominant risk, and why it still composes with a second factor rather than skipping one.

You hand-roll none of this. Magic links ship as a Better Auth plugin, which means two registrations that must stay in sync: the server plugin magicLink() in the plugins array in lib/auth.ts, and its client half magicLinkClient() in lib/auth-client.ts. Forget the client half and authClient.signIn.magicLink will not exist.

The server plugin takes four options: three you will recognize from the verification and reset flows, and a fourth whose default works against you.

The send callback has the same shape as sendVerificationEmail and sendResetPassword:

sendMagicLink: async ({ email, url }) => {
await sendEmail({
to: email,
subject: 'Your sign-in link',
react: MagicLinkEmail({ url }),
});
}

The library mints the token and builds the full url; you only put it in front of the user, through the same Resend wrapper you built earlier. The MagicLinkEmail template is the verification email’s twin: one call to action, a plain-text fallback URL for clients that strip the button, and a line saying how soon it expires.

The expiry belongs on a ladder you have been climbing all chapter:

Verify email proves you can read the inbox — low stakes
1 hour
Password reset grants a credential change
10 minutes
Magic link the link IS the credential
5 minutes
One ladder, longest fuse at the top. The shorter the fuse, the higher the stakes — and the magic link, where the click itself is the credential, gets the shortest of all.
expiresIn: 60 * 5, // 5 minutes

A verification link can live an hour, because all it proves is that you can read your own inbox. A reset link gets ten minutes, because clicking it lets you change a credential. A magic link gets five, the shortest fuse of the three, because the link does not let you sign in, clicking it signs you in, with no password prompt behind it to catch a stolen link.

The next option controls whether a brand-new email can create an account:

disableSignUp: false,

At the default false, a magic link sent to an unknown address creates that user on the click, which is what you want. Set it to true and only existing users can sign in, but then the response to an unknown email must stay identical to the one for a known address. The instant you show “no account found, sign up first,” you rebuild the enumeration oracle that tells an attacker which emails are real.

The fourth option has a default you need to override. Better Auth defaults to storeToken: 'plain', storing the raw token in plaintext in the verification row. The verification flow hashed its token before writing it, so a snapshot of that table was inert; here, out of the box, it is not. Anyone who reads a row of your database, through a leaked backup or a misconfigured replica, reads a live sign-in link.

You already know the property this violates: exfiltrating the verification table must yield zero working links. The fix is one setting:

storeToken: 'hashed', // overrides the 'plain' default; never store a live link

It is easy to miss, because the happy path works the same with or without it. The gap stays invisible until the breach that surfaces it.

Here is the whole magicLink() call. Step through it, and the highlight will move to one option at a time.

export const auth = betterAuth({
// ...adapter, emailAndPassword, etc.
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => {
await sendEmail({
to: email,
subject: 'Your sign-in link',
react: MagicLinkEmail({ url }),
});
},
expiresIn: 60 * 5,
disableSignUp: false,
storeToken: 'hashed',
}),
],
});

The send callback. The library hands you a ready-made url; you forward it through the same sendEmail Resend wrapper you used for verification and reset. You mint nothing.

export const auth = betterAuth({
// ...adapter, emailAndPassword, etc.
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => {
await sendEmail({
to: email,
subject: 'Your sign-in link',
react: MagicLinkEmail({ url }),
});
},
expiresIn: 60 * 5,
disableSignUp: false,
storeToken: 'hashed',
}),
],
});

Five minutes, the shortest fuse of any token in the chapter, because clicking the link is the sign-in itself, with no password step behind it to catch a stolen one.

export const auth = betterAuth({
// ...adapter, emailAndPassword, etc.
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => {
await sendEmail({
to: email,
subject: 'Your sign-in link',
react: MagicLinkEmail({ url }),
});
},
expiresIn: 60 * 5,
disableSignUp: false,
storeToken: 'hashed',
}),
],
});

The default. A first-time email creates the account on the click. Flip it to true only if you keep the unknown-email response identical to the known one, otherwise you reopen the enumeration leak.

export const auth = betterAuth({
// ...adapter, emailAndPassword, etc.
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => {
await sendEmail({
to: email,
subject: 'Your sign-in link',
react: MagicLinkEmail({ url }),
});
},
expiresIn: 60 * 5,
disableSignUp: false,
storeToken: 'hashed',
}),
],
});

The load-bearing override. The library defaults this to 'plain' and stores the raw token; 'hashed' is what keeps a leaked verification row from being a working sign-in link.

1 / 1

The token under all of this is a bearer token , generated by a CSPRNG so it cannot be guessed. That is why the two mitigations matter: hashing keeps the stored copy inert, and the short expiry keeps a stolen copy useful only briefly.

Four things happen between submitting the email and landing on the dashboard, and the library does the token work you already know.

  1. Submit. The browser calls authClient.signIn.magicLink({ email, callbackURL: '/dashboard' }). The library writes a verification row namespaced magic-link:<email> and fires your sendMagicLink callback.
  2. Confirm. The form shows the enumeration-safe “check your inbox,” echoing the email back and never branching on whether the address is known.
  3. Click. The link hits …/api/auth/magic-link/verify?token=<token>&callbackURL=<dest> on the same [...all] catch-all from setup, so there is no new route. The lookup is single-use and atomic: a second click finds nothing and lands on ?error=INVALID_TOKEN (uppercase, like the reset endpoint), and a fresh link supersedes the old one, leaving only the most recent live.
  4. Land. The user arrives on /dashboard signed in. The click was the credential.

The session is issued at click-time. Watch where that happens in the sequence below: it is the structural difference between this and password sign-in.

Browser the user
Better Auth [...all] route
DB verification row
Inbox the credential
signIn.magicLink({ email })
No session yet — the click has not happened
Submit. The browser calls authClient.signIn.magicLink({ email, callbackURL }). Nothing is signed in yet — this only asks for a link.
Browser the user
Better Auth [...all] route
DB verification row
Inbox the credential
write magic-link:<email>
No session yet — the click has not happened
Mint. Better Auth generates a token and writes a verification row under magic-link:<email> — the same one-table, many-namespaces pattern as verify and reset.
Browser the user
Better Auth [...all] route
DB verification row
Inbox the credential tap
sendMagicLink → click
No session yet — the click has not happened
Deliver. Your sendMagicLink callback sends the email through Resend; it lands in the inbox and the user taps the link. The request has left the system — it now waits in the inbox.
Browser the user
Better Auth [...all] route
DB verification row
Inbox the credential
consume token · issue session
Session issued — the user is signed in
Verify. The [...all] route consumes the token, finds-or-creates the user, and issues the session. This is the moment of authentication — a full inbox round-trip after submit, where password sign-in would have signed in on submit.
Browser the user
Better Auth [...all] route
DB verification row
Inbox the credential
redirect → /dashboard
Session issued — the user is signed in
Land. The browser is redirected to /dashboard, already signed in. No password was ever typed — the click was the credential.

Two patterns ride along. Any callbackURL your own landing code reflects into a redirect must pass through safeNext first, since Better Auth validates only the redirects it controls through trustedOrigins. And never log the magic-link URL or its token: it is a bearer credential, usable by anyone with log access.

Section titled “Five ways magic links differ from the other flows”

You already hold every primitive this flow needs. What is left is judgment: five places where magic links behave differently from the flows before.

The link is a bearer token in someone’s browser. Better Auth is browser-agnostic by default: the token authenticates whoever opens it, wherever, which is what lets a user tap the email on a phone and sign in on a laptop. The only check is the five-minute expiry, so a forwarded or leaked link is access. Pinning the link to the browser that requested it is hand-built — set a paired cookie on request, check it on the click. There is no sameBrowser option in magicLink().

Magic links and passwords coexist, but you have to communicate the choice. Nothing conflicts underneath: the password lives on the 'credential' account row, the magic-link flow never touches it, and either path issues the same session. The trap is the interface. Showing an email field, a password field, and a magic-link field as equals stalls the user. Make email and password the primary path, and offer “Email me a link instead” as a clearly secondary alternative.

The first click is the sign-up. With disableSignUp at its default, a link sent to an address you have never seen creates that user on the click, with emailVerified already true. Clicking the delivered link is the proof of inbox control that verification exists to establish, so delivery and verification become one act — one round-trip instead of the two a password sign-up takes. Pass newUserCallbackURL to land first-timers on onboarding rather than the dashboard.

Deliverability is the dominant risk, so design for the bad day. The failure mode is no longer “wrong password,” it is “the email never came.” On the check-inbox view, surface “check your spam folder, then resend” rather than burying it, and rate-limit the resend to roughly one send every thirty seconds (wired up later in the course). Above all, always keep email and password as a fallback — magic links as the only way in means a single delivery outage locks out every user at once.

A second factor still applies. A magic-link click proves control of the email account, which is one factor. An account enrolled in two-factor authentication still gets prompted for the second factor after the click: magic links swap the password factor for an inbox-control factor, they do not waive the second factor. The next lesson covers it.

Now put the five adjustments against a product call.

Your team proposes shipping magic links as the only sign-in method for a B2B dashboard that customers open every weekday morning. Which objections are valid? Select all that apply.

A product people open daily turns the inbox detour into friction they pay at the start of every shift.
With nothing else to fall back on, a bad day for email delivery means the whole customer base is shut out simultaneously.
Strict corporate mail filtering can quietly swallow the sign-in link, so it never reaches the person waiting on it.
Turning on magic links forces two-factor authentication off, so the dashboard can no longer require a second factor.
There is no safe way to keep a magic-link token at rest, so the approach is unsound by construction.

The emailOTP() plugin is a near-twin of magic links: instead of a URL the user clicks, it emails a six-digit OTP they type back into the form. The machinery underneath is identical, a token in a verification row, short expiry, single use, the same enumeration discipline, with only the typed code differing. Reach for it where a clicked link struggles: when the user is on a separate device with no easy way to follow a link, or when email clients mangle or pre-fetch link URLs and trip the token early.

The plugin reference and the wider trade-off backdrop, for when you make this call on a real product: