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
- The person asks for a reset on
/forgot-password. The server stores a random token (24 characters,reset-password:<token>in theverificationtable) with an expiry, and emails<BETTER_AUTH_URL>/api/auth/reset-password/<token>?callbackURL=/reset-password. - 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_TOKENwhen it has. - 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:
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
Refererheader. Setreferrer: "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: truesigns out every device, including an attacker's. - A reset gives an OAuth-only account a password. Better Auth creates a
credentialaccount 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.