# Password reset tokens that cannot be replayed, guessed or leaked

> One hour, single use, answered the same way for every address, and kept out of logs, Referer headers and search results. What Better Auth does for you and the four things it cannot.

A password reset link is a credential with a short fuse: whoever opens it first
chooses the password. Most reset bugs are not in the token itself but in what
happens around it. This is the checklist.

## What the flow looks like

1. The person asks for a reset on `/forgot-password`. The server stores a
   random token (24 characters, `reset-password:<token>` in the `verification`
   table) with an expiry, and emails
   `<BETTER_AUTH_URL>/api/auth/reset-password/<token>?callbackURL=/reset-password`.
2. Opening the link hits Better Auth first. It checks the token exists and has
   not expired, then redirects to `/reset-password?token=<token>`, or to
   `/reset-password?error=INVALID_TOKEN` when it has.
3. The page posts the new password with the token. Better Auth **consumes** the
   token (a second use fails), hashes the password and, with
   `revokeSessionsOnPasswordReset: true`, deletes every session the old
   password opened.

Better Auth handles the token: random, stored server-side, single use,
expiring (`resetPasswordTokenExpiresIn`, one hour by default). The rest is
yours.

## 1. Answer every request the same way

The request endpoint returns `{ status: true }` whether or not the address has
an account, and your page must too: one "check your inbox" panel, worded "if
this address has an account". A form that says "no account with that email"
lets anyone test a list of addresses against your user table.

Timing leaks the same fact. Sending an email takes hundreds of milliseconds;
not sending one takes none. Better Auth runs the send through
`advanced.backgroundTasks` when you configure it, so the response goes out
before the email does:

```ts
import { after } from "next/server";

betterAuth({
  advanced: {
    backgroundTasks: {
      handler: (task) => {
        try {
          after(task); // Next.js: run after the response is sent
        } catch {
          // Outside a request (a script): the task is already running.
        }
      },
    },
  },
});
```

A side effect you want: a mail provider outage no longer turns the request into
a 500. The failure is logged, and the person asks again.

## 2. Keep the token out of places that outlive the request

- **Referer.** The reset page URL contains the token. Any image, font or script
  the page loads from another origin receives it in the `Referer` header. Set
  `referrer: "no-referrer"` in the page's metadata, and load nothing third
  party on it.
- **Search engines and link previews.** `robots: { index: false }` on the
  reset page. Do not paste reset links into chat tools that unfurl them.
- **Logs.** Never log the URL the email contains. Log that a reset was sent,
  to which user id, and nothing else.
- **Analytics.** An analytics script on the reset page records the full URL,
  token included. Exclude the route.

## 3. Make "expired" a page, not a stack trace

Two ways to arrive with a bad token: the link expired or was used, and Better
Auth redirects with `?error=INVALID_TOKEN`; or the token was fine on arrival
but used up before the form was submitted (a second tab), and the reset call
answers `INVALID_TOKEN`. Both should show one sentence ("This reset link has
expired or was already used") and a button to ask for a new one. Neither
should show the form.

## 4. Decide what a reset proves

A reset proves the person controls the mailbox. Two consequences:

- **End other sessions.** Someone resetting a password usually suspects the old
  one leaked. `revokeSessionsOnPasswordReset: true` signs out every device,
  including an attacker's.
- **A reset gives an OAuth-only account a password.** Better Auth creates a
  `credential` account when none exists. That is correct (the mailbox owner is
  the account owner), but know it happens: an account that signed up with
  Google can reach "Forgot password" and end up with both.

## Rate limits

`/request-password-reset` sends email to any address you type. Without a limit
it is a free relay for mailing strangers from your verified domain. Better
Auth applies a strict built-in rule to it (three requests a minute per IP, the
same as for resending a verification email), and that rule only means
something when the counter is shared:
`rateLimit.storage: "database"` on serverless, never the in-memory default.

## Checking your work

- Request a reset for a real address and a made-up one. Same panel, same
  response body, similar response time.
- Open the link, set a password, then open the same link again: the expired
  message, with a way to ask for another.
- Sign in on a second browser first; after the reset it is signed out.
- Look at the reset page's network tab: no request to another origin carries
  the token in its `Referer`.

---

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
