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.
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:
POST /sign-in/magic-linkgenerates a 32-character random token and writes a row in theverificationtable with{ email, name, attempt: 0 }and an expiry ofexpiresInseconds (default 300).- It builds
<baseURL>/magic-link/verify?token=...&callbackURL=...and hands it to yoursendMagicLink. With Better Auth mounted at/api/auththat ishttps://<your-api>/api/auth/magic-link/verify?..., sobaseURLhas to be the public URL of the server that mounts it. GET /magic-link/verifylooks the token up, checks expiry, increments theattemptcounter and creates a session. WithcallbackURLit 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
| Option | Default | What to do |
|---|---|---|
expiresIn | 300 (seconds) | Keep it short. Five minutes is a good default. |
disableSignUp | false | Set 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/sendalways 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
signInMagicLinkdeclaresrequireHeaders: true. Callingauth.api.signInMagicLinkwithout forwarding headers throwsHeaders 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
sendMagicLinkcallback 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/verifyis in the list of single-factor paths theafterhook checks. For a user withtwoFactorEnabled, 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 throughauth.api, copies theSet-Cookieheaders and redirects to${APP_URL}/dashboard, or to/login?error=magic-link-invalidon 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
| Symptom | Cause |
|---|---|
Headers is required | Server-side signInMagicLink call without request headers. |
Link opens localhost or the wrong host | BETTER_AUTH_URL is not the public URL of the API. |
ATTEMPTS_EXCEEDED when the user clicks | Something 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_disabled | disableSignUp: true and the email has no account. Expected. |
| No email, no error | Your 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.