This is the auth half of passwordless sign-in: the token, its lifetime, and the ways it is consumed by something other than the person it was sent to.
Getting the message delivered is the other half, and it belongs to your email
battery: SPF, DKIM, DMARC, sending subdomains, bounce handling. Its solution
doc is next to this one under docs/solutions/. Read it first if the complaint
is "no email arrived"; read this one if the complaint is "the link did not
work".
What the token is
src/lib/auth/auth.ts configures magicLink() from one constant, declared in
src/lib/auth/policy.ts because three different bundles need to agree on it:
export const MAGIC_LINK_TTL_MINUTES = 10;
Better Auth mints a random token, stores a hash of it in the verification
table, and deletes the row when it is redeemed. That gives you three properties
without writing any of them yourself: unpredictable, single-use, and expiring.
Never build a second kind of sign-in token beside it. A hash of a user id, a
signed timestamp, a nanoid() in a query string: each of those is a credential
your own code now has to get right, and Better Auth already got this one right.
See the "auth server boundary" rule.
Ten minutes, not an hour
MAGIC_LINK_TTL_MINUTES is read in three places: the plugin that mints the
token, the email template that tells the recipient how long they have, and the
sign-in form's "check your inbox" copy. Change it in policy.ts and all three
follow, which is the point of it living in a module with no dependencies
rather than in the auth config a client component cannot import.
Longer is worse than it looks. The link sits in a mailbox: one that may be shared, synced to a phone someone lost, or backed up somewhere you cannot see. Ten minutes is enough for a person who asked for it and short enough that a leaked mailbox is not a standing invitation.
If ten minutes is genuinely too short for your users, the answer is usually that the mail is slow, not that the token is short-lived. Measure the send before you raise the number.
A scanner opens the link before the human does
This is the failure that produces the best bug report: "the link says it is already used, but I never clicked it."
Corporate mail security (Microsoft Defender for Office 365, Proofpoint, Mimecast, plenty of others) fetches every URL in an inbound email to check it. The fetch happens before delivery, from a data centre, with a browser-like user agent. A single-use magic link is consumed by that fetch, and by the time the human clicks, the token is gone.
There is no reliable way to detect a scanner. What actually works:
Make the emailed URL a GET that confirms, not a GET that completes. The
link lands on a page with a single "Sign me in" button that POSTs the token.
Scanners follow links; they do not submit forms. The token survives, and it
costs the user one extra click. This is the fix: everything else is mitigation.
Keep the raw URL visible in the email as text. Some scanners rewrite links into their own domain in a way that breaks them entirely, and a copyable URL is the way through. The shipped template already prints it below the button; do not remove it to tidy the design.
Do not lengthen the expiry to compensate. A scanner clicks within seconds. More time changes nothing about this failure and makes the leaked-mailbox one worse.
Whatever you choose, the "link is invalid or expired" page must offer a one-click way to request a fresh link. Most people who hit it are not attackers, they are the victims of their own IT department.
Rate limiting is part of the token story
auth.ts sets rateLimit.storage: "database" and gives the magic-link
endpoints their own rule (five requests per minute, per client IP). Both matter:
- Database storage, because the app runs on serverless functions. Better
Auth's default counter lives in process memory, so a fleet of warm instances
multiplies every limit by however many are running. The counters live in the
rate_limittable instead, which is why that table is in your migrations. - The per-endpoint rule, because
/sign-in/magic-linksends mail from your verified domain to any address a caller names. Unlimited, it is a free relay for someone else's spam and your sending reputation pays for it.
The bucket is keyed on the client IP, which means it only works if the app can
see one. advanced.ipAddress.ipAddressHeaders in auth.ts is set for this
stack's deployment target; behind any other proxy, add trustedProxies too.
When Better Auth cannot resolve an address it logs a warning and falls back to a
single shared bucket for every visitor, at which point the first few people
to sign in each minute lock out everybody else. If sign-in starts returning 429
to people who have not tried before, that warning is the thing to look for.
Rate limiting in the UI is not rate limiting. magic-link-form.tsx renders a
distinct message for 429 because the server sends one; disabling the button
stops an impatient human and nothing else.
Do not turn the form into an oracle
The response to "email me a link" is identical for a known and an unknown address: same copy, same status, same timing. Anything else is an account-enumeration endpoint with a nice UI on it, and "improving" the error handling is the usual way it gets broken.
The same applies to the redemption side. "This link is invalid or expired" is the only message. Distinguishing "no such token" from "expired token" tells an attacker which of their guesses existed.
Checking your work
- Request a link, open it, then open it again. The second attempt shows a clear expired message with a way to ask for another.
- Request links for a real address and a nonexistent one. The two responses are byte-identical.
- Request six links inside a minute. The sixth answers 429 and the form says so rather than showing a generic failure.
- Deploy, then check the logs for Better Auth's "could not determine a client IP" warning. If it is there, the limiter is one bucket for the whole internet.
- After signing in on a preview deployment, confirm you land on
/dashboardand not on an origin error. That isVERCEL_URLreachingtrustedOrigins.