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.
When to choose magic links
Section titled “When to choose magic links”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.
Daily sign-in makes the inbox round-trip friction on every session. The password default already serves this product well.
A hard MFA requirement is cleaner to reason about with a password as the first factor. Offer a magic link as a convenience alternative, but not as the only way in.
Infrequent sign-in plus reliable delivery is the sweet spot: the password-reuse risk is gone and reset tickets drop. This is where passwordless pays for itself.
When delivery is unreliable, sign-in can never hang on a single email landing. Offer the magic link as the friendly path, and keep the password so an outage locks no one out.
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.
Installing and configuring the plugin
Section titled “Installing and configuring the plugin”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:
expiresIn: 60 * 5, // 5 minutesA 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 linkIt 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.
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.
The flow, from email to session
Section titled “The flow, from email to session”Four things happen between submitting the email and landing on the dashboard, and the library does the token work you already know.
- Submit. The browser calls
authClient.signIn.magicLink({ email, callbackURL: '/dashboard' }). The library writes averificationrow namespacedmagic-link:<email>and fires yoursendMagicLinkcallback. - Confirm. The form shows the enumeration-safe “check your inbox,” echoing the email back and never branching on whether the address is known.
- 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. - Land. The user arrives on
/dashboardsigned 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.
authClient.signIn.magicLink({ email, callbackURL }).
Nothing is signed in yet — this only asks for a link.
verification row under magic-link:<email> — the same one-table,
many-namespaces pattern as verify and reset.
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.
[...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.
/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.
Five ways magic links differ from the other flows
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.
storeToken: 'hashed'.Email OTP: a typed code instead of a link
Section titled “Email OTP: a typed code instead of a link”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.
External resources
Section titled “External resources”The plugin reference and the wider trade-off backdrop, for when you make this call on a real product:
The canonical option reference: sendMagicLink, expiresIn, disableSignUp, storeToken, and more.
The sibling plugin — a typed six-digit code instead of a clicked link.
A practitioner's weigh-up of magic links against passwords, deliverability front and center.
The enumeration and credential discipline that underpins every flow in this chapter.