# 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.

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:

```ts
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_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`.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
