Better Auth magic link: setup, security defaults, and the gotchas

Set up Better Auth magic link sign-in: sendMagicLink, expiry, disableSignUp, token storage, rate limits and the 2FA bypass you have to close yourself.

MC
Martin Coll

To add magic link sign-in to Better Auth, register the magicLink plugin with a sendMagicLink function that emails the url it receives, and call signIn.magicLink({ email, callbackURL }) from the client. The plugin stores a single-use token that expires in 5 minutes, and clicking the link creates the session. The defaults are permissive in three places: it creates accounts for unknown emails, it stores the token in plain text, and it does not respect your two-factor setup. This guide is checked against [email protected], the version useDeploy ships, and its source.

Minimal setup

The magic link plugin docs show the shape:

import { betterAuth } from 'better-auth';
import { magicLink } from 'better-auth/plugins/magic-link';

export const auth = betterAuth({
  plugins: [
    magicLink({
      sendMagicLink: async ({ email, token, url }) => {
        // send `url` to `email` with your email provider
      },
    }),
  ],
});
const { data, error } = await authClient.signIn.magicLink({
  email: '[email protected]',
  callbackURL: '/dashboard',
});

You also need magicLinkClient() in the client plugins to get the typed signIn.magicLink call. Only email is required in the body. Optional fields are name, newUserCallbackURL, errorCallbackURL and metadata.

What the plugin does

Reading plugins/magic-link/index.mjs in 1.6.9:

  1. POST /sign-in/magic-link generates a 32-character random token and writes a row in the verification table with { email, name, attempt: 0 } and an expiry of expiresIn seconds (default 300).
  2. It builds <baseURL>/magic-link/verify?token=...&callbackURL=... and hands it to your sendMagicLink. With Better Auth mounted at /api/auth that is https://<your-api>/api/auth/magic-link/verify?..., so baseURL has to be the public URL of the server that mounts it.
  3. GET /magic-link/verify looks the token up, checks expiry, increments the attempt counter and creates a session. With callbackURL it redirects; without one it answers JSON with the session.

Errors on that path come back as redirects to your errorCallbackURL with an error query value: INVALID_TOKEN, EXPIRED_TOKEN, ATTEMPTS_EXCEEDED and new_user_signup_disabled. The default allowedAttempts is 1, so a second click on the same link fails with ATTEMPTS_EXCEEDED. The docs now describe that option as deprecated and say tokens are consumed on first verification, so treat the link as single-use either way.

Options that matter

OptionDefaultWhat to do
expiresIn300 (seconds)Keep it short. Five minutes is a good default.
disableSignUpfalseSet it to true unless you want sign-in to double as sign-up.
storeToken'plain'Use 'hashed' so a database leak does not hand out live links.
rateLimit{ window: 60, max: 5 }Per IP on the sign-in and verify paths. See below.

Close the account-creation hole

Without disableSignUp: true, any email submitted to /sign-in/magic-link gets an account created on first verification, with emailVerified: true. Anyone can then register throwaway addresses through your endpoint. If your product has an explicit sign-up flow, switch it off. Unknown emails then end at new_user_signup_disabled.

The token is plain text by default

With storeToken: 'plain' the verification identifier is the token itself, so whoever can read your verification table can sign in as anyone with a link still alive. 'hashed' stores a digest instead. The cost is small and the window is only expiresIn seconds, but it is one line.

The 2FA bypass

A magic link proves access to an inbox, which is one factor. In 1.6.9 the two-factor plugin only challenges password sign-ins, so a user who enabled TOTP can still be let in by a magic link with no code. If you offer both, you need a hook. See Better Auth 2FA with TOTP for the full reasoning.

How useDeploy handles it

useDeploy's plugin setup, in apps/server/src/modules/iam/infrastructure/auth/better-auth.adapter.ts:

magicLink({
  // Without this, BetterAuth auto-creates an account for any email
  // submitted to /sign-in/magic-link.
  disableSignUp: true,
  sendMagicLink: async ({ email, url, token }) => {
    await cfg.sendMagicLink({ email, url, token });
  },
}),

expiresIn and storeToken are left at their defaults: links last 5 minutes (the email text says so) and tokens are stored in plain text. If you fork it, hashed is the first change I would make.

Around the plugin, useDeploy adds several things the plugin does not do:

  • No account enumeration. POST /api/v1/auth/magic-link/send always answers { ok: true }. The use case (send-magic-link.ts) looks the user up first and returns silently if the email is unknown, so nobody can probe which addresses are registered, and no mail goes to strangers.
  • Server-side calls need request headers. The plugin's signInMagicLink declares requireHeaders: true. Calling auth.api.signInMagicLink without forwarding headers throws Headers is required. The adapter forwards them. Because the controller masks failures behind the uniform 200, forgetting this is silent: no mail, no error. Log the failure.
  • Delivery is synchronous. The sendMagicLink callback renders a React Email template and calls the configured provider (Resend or SMTP, or a log provider when neither is set) before answering. A queue would race the response.
  • The 2FA hook. /magic-link/verify is in the list of single-factor paths the after hook checks. For a user with twoFactorEnabled, the fresh session is deleted. Clicking the native link from the email (/api/auth/magic-link/verify) sends the browser to /login?error=two-factor-required; through the wrapped route below, the same user lands on /login?error=magic-link-invalid.
  • A wrapped verify route. GET /api/v1/auth/magic-link/verify?token= calls the plugin's verify through auth.api, copies the Set-Cookie headers and redirects to ${APP_URL}/dashboard, or to /login?error=magic-link-invalid on any failure. A 2xx from the plugin counts as success, because every failure is a redirect or a 4xx.

Rate limits

Better Auth ships its own rule for these paths: /sign-in/magic-link and /magic-link/verify allow 5 requests per 60 seconds per IP, configurable with the plugin's rateLimit option. That limiter only runs for HTTP requests to Better Auth's handler, and only when NODE_ENV=production. useDeploy's wrapped routes go through auth.api, so they rely on the app's own limiters instead (authEmailLimiter on send, authIpLimiter on verify). The link in the email itself hits Better Auth's handler under /api/auth, where both layers apply. The email limiter keys on the address, which stops one inbox from being flooded from many IPs. The details are in Better Auth rate limiting.

Common errors

SymptomCause
Headers is requiredServer-side signInMagicLink call without request headers.
Link opens localhost or the wrong hostBETTER_AUTH_URL is not the public URL of the API.
ATTEMPTS_EXCEEDED when the user clicksSomething opened the link first, often an email scanner. Show a confirmation page and verify on a button press if this hits your users.
new_user_signup_disableddisableSignUp: true and the email has no account. Expected.
No email, no errorYour provider failed and the handler swallowed it. Check the logs.

Frequently asked questions

How long does a Better Auth magic link last?

300 seconds by default. Change it with expiresIn, in seconds.

Can the same link be used twice?

No. The second verification fails, and the docs describe tokens as consumed on first use.

Does a magic link verify the user's email?

Yes. A successful verification sets emailVerified to true on the account.

Should I use magic link and passwords together?

It works, but decide how 2FA applies. Otherwise the link bypasses the code, as covered above.

Is the link tied to a device or IP?

No. Whoever opens the link first gets the session, which is why the expiry is short.


useDeploy ships magic link sign-in with account creation off, uniform responses, the 2FA guard and the email template. See the Better Auth integration and the auth docs.