Skip to content

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.

Better Auth4 min readships at docs/solutions/better-auth/password-reset-tokens.md

Tags: better-auth · passwords · reset · tokens · security

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:

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.