Password Reset Flow#
The password reset flow is handled by three endpoints in packages/better-auth/src/api/routes/password.ts and is activated only when emailAndPassword.sendResetPassword is configured.
Endpoints#
| Endpoint | Purpose |
|---|---|
POST /request-password-reset | Generate token, call sendResetPassword callback |
GET /reset-password/:token | Validate token, redirect to client with ?token= |
POST /reset-password | Consume token atomically, update password |
Step-by-Step Flow#
1. Request (POST /request-password-reset)#
Accepts { email, redirectTo? } .
- Enumeration protection: if the user is not found, the handler still calls
generateId(24)and a dummy DB lookup to equalize timing before returning a generic success message . - Generates a 24-character random token via
generateId(24). - Stores the token in the
verificationtable under the keyreset-password:{token}, with the user's ID as the value and a configurable TTL (default: 1 hour) . - The configurable expiry is
emailAndPassword.resetPasswordTokenExpiresIn(in seconds) . - Calls
emailAndPassword.sendResetPassword({ user, url, token }, request)in the background . Theurlembeds the token and the encodedredirectToascallbackURL.
2. Callback (GET /reset-password/:token)#
Reads the verification row for reset-password:{token} and checks expiresAt .
- Expired or missing tokens redirect to
callbackURL?error=INVALID_TOKEN. - Valid tokens redirect to
callbackURL?token={token}.
Note: This GET endpoint does not consume the token. It only validates expiry and redirects. Token consumption happens in the POST below.
3. Reset (POST /reset-password)#
Accepts { newPassword, token } (token can also come from ?token query param) .
- Password length is validated against
minPasswordLength/maxPasswordLength. - Atomic single-use consumption: calls
internalAdapter.consumeVerificationValue("reset-password:{token}")— this atomically deletes and returns the row . The first concurrent caller wins; any racer (or expired token) getsnull→BAD_REQUEST INVALID_TOKEN. - On success: hashes the new password and either creates a
credentialaccount (if none exists) or updates the existing one . - Post-reset hooks (configuration-driven):
emailAndPassword.onPasswordReset({ user }, request)fires if configured .emailAndPassword.revokeSessionsOnPasswordReset = truedeletes all sessions for the user after a successful reset .
Single-Use Token Guarantee#
Token consumption uses consumeVerificationValue — the higher-level atomic primitive built on consumeOne. It deletes-and-returns the row in a single atomic operation; a racer getting null is rejected immediately. Tokens past expiresAt are still deleted on consumption (no replay possible) but null is returned to the caller.
Plugin Hook Architecture#
Password reset handlers participate in the standard request lifecycle hook pipeline: runBeforeHooks → handler → runAfterHooks via dispatchAuthEndpoint. Plugins can intercept both /request-password-reset and /reset-password using the standard hooks.before / hooks.after matcher pattern .
Example — haveIBeenPwned plugin : Rather than a hooks.before route matcher, this plugin overrides the ctx.password.hash function in its init() hook. When any endpoint on the default path list (including /reset-password) calls password.hash(), the wrapper intercepts it, sends the SHA-1 prefix to api.pwnedpasswords.com using k-anonymity, and throws PASSWORD_COMPROMISED before the hash ever completes . This blocks a compromised new password before the credential is written.
Plugins can also intercept via hooks.before to short-circuit or mutate context, or hooks.after to react to the successful reset (e.g., audit logging). See Request Lifecycle Hooks for the full pattern.
Alternative Reset Mechanisms#
The core flow above is email-link-based. Two plugins provide OTP-based alternatives on the same general structure:
emailOTPplugin —POST /email-otp/request-password-reset+POST /email-otp/reset-passwordphoneNumberplugin —POST /phone-number/request-password-reset+POST /phone-number/reset-password
Both use atomicVerifyOTP / consumeVerificationValue for the same single-use guarantee.
Key Source Files#
| File | Purpose |
|---|---|
api/routes/password.ts | All three core reset endpoints |
plugins/haveibeenpwned/index.ts | password.hash override for HIBP checks |
core/src/types/init-options.ts | emailAndPassword.* config types |
api/dispatch.ts | runBeforeHooks, runAfterHooks, dispatchAuthEndpoint |
| Atomic Database Operations | consumeVerificationValue internals |
| Request Lifecycle Hooks | Hook registration and execution model |