Skip to content

The magic-link token: single use, ten minutes, and the scanner that clicks it first

How long the credential lives, why a corporate mail scanner burns it before the human arrives, and why the rate limiter has to be backed by your database rather than by process memory.

Better Auth6 min readships at docs/solutions/better-auth/magic-link-tokens-and-scanners.md

Tags: better-auth · magic-link · tokens · rate-limit · security

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.

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_limit table instead, which is why that table is in your migrations.
  • The per-endpoint rule, because /sign-in/magic-link sends 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 /dashboard and not on an origin error. That is VERCEL_URL reaching trustedOrigins.