Skip to content
Chapter 53Lesson 4

Password reset

Build a secure forgot-password flow with Better Auth, where an emailed reset link rotates the password and revokes every existing session.

The last three lessons all assumed the user still remembers their password. Password sign-up stored a hash, Password sign-in checked it, and Email verification proved the inbox was real, but each one breaks the moment the user types the wrong password and can’t fix it. This lesson handles that moment.

It’s also the most security-sensitive of the four, because a reset is the one flow designed to hand account access to someone who can’t currently prove who they are. Sign-in says “prove it, then come in.” Reset says “you can’t prove it, so let’s establish a new way in.” Build it naively and you’ve shipped a back door with a polite interface. By the end of the lesson a forgotten password becomes a fresh one through a link in the inbox, and every stale session for that account dies in the process.

Three questions hide behind what looks like a two-screen form:

  • What does the request endpoint answer for an email that doesn’t exist? The door has to give the same reply to a real address and a fake one.
  • How long does the link live, and why is that shorter than the verification link? It reuses last lesson’s token machinery with a tighter expiry.
  • What side effect on success separates a secure reset from a back door? That’s the spine of the lesson.

Changing your password from account settings is the signed-in sibling of this flow; it belongs to a later chapter and reuses one rule you’ll learn here. Recovery codes for the lost-second-factor case and the rate limits on every endpoint below are named where they touch this flow, not built here.

The shape of a reset: six steps, two actions, one new rule

Section titled “The shape of a reset: six steps, two actions, one new rule”

A reset is two Server Actions, one to request the link and one to submit the new password, wrapped around a single round-trip through the user’s inbox. Each step has a way it fails in production, named next to the mechanic:

  1. Request. The user submits their email on /forgot-password. The action calls auth.api.requestPasswordReset. Better Auth mints a random token, stores its hash in a verification row, and fires your sendResetPassword callback. Failure mode: leaking whether that email belongs to a real account.
  2. Uniform response. The form renders “if an account exists, we’ve emailed a link,” the same line whether the email was real or not. Failure mode: an “email not found” tell that turns the form into an account-discovery tool.
  3. Click. The user opens their inbox and clicks …/reset-password?token=<token>. That page is interactive: a new-password field and a confirm field. Failure mode: the token reflected into a log or analytics breadcrumb.
  4. Submit. The action calls auth.api.resetPassword. Better Auth hashes the incoming token, finds the row, checks it hasn’t expired, validates the new password, writes the new hash, and deletes the row. Failure mode: a stale or already-used link still working.
  5. Invalidate. Better Auth ends every existing session for that user. Failure mode: skipping this step, which leaves an attacker holding the old password still signed in.
  6. Land. The user is signed in fresh with one new session, or bounced to /sign-in for high-stakes products, with a one-time success message.

Steps 1 through 4 are wiring you’ve done three times now. Step 5 is the only new idea, and the reason a secure reset is harder than “email a link to whoever asks.”

The same flow as a sequence you can scrub through. Watch the invalidation step: the dying sessions are the heart of the lesson in one frame.

1 Submit
email
2 Mint +
hash token
3 Email
link
4 User
clicks
5 Validate
token
6 Hash new
password
7 Kill all
sessions
8 Sign in
fresh
live sessions on every device
Laptop old session Phone old session Attacker stolen password Laptop new session
The user typed their email on /forgot-password and pressed send. The request leg begins.
1 Submit
email
2 Mint +
hash token
3 Email
link
4 User
clicks
5 Validate
token
6 Hash new
password
7 Kill all
sessions
8 Sign in
fresh
live sessions on every device
Laptop old session Phone old session Attacker stolen password Laptop new session
Better Auth generated a random token and stored only its hash in a verification row. The raw token never touches the database.
1 Submit
email
2 Mint +
hash token
3 Email
link
4 User
clicks
5 Validate
token
6 Hash new
password
7 Kill all
sessions
8 Sign in
fresh
live sessions on every device
Laptop old session Phone old session Attacker stolen password Laptop new session
Your sendResetPassword callback delivered the link through Resend. The library is now waiting — nothing happens until the user acts.
1 Submit
email
2 Mint +
hash token
3 Email
link
4 User
clicks
5 Validate
token
6 Hash new
password
7 Kill all
sessions
8 Sign in
fresh
live sessions on every device
Laptop old session Phone old session Attacker stolen password Laptop new session
The user opens their inbox and clicks the link. The token rides in the URL. The reset leg begins.
1 Submit
email
2 Mint +
hash token
3 Email
link
4 User
clicks
5 Validate
token
6 Hash new
password
7 Kill all
sessions
8 Sign in
fresh
live sessions on every device
Laptop old session Phone old session Attacker stolen password Laptop new session
Better Auth hashed the incoming token, found the matching row, and confirmed it hasn't expired. The ten-minute fuse held.
1 Submit
email
2 Mint +
hash token
3 Email
link
4 User
clicks
5 Validate
token
6 Hash new
password
7 Kill all
sessions
8 Sign in
fresh
live sessions on every device
Laptop old session Phone old session Attacker stolen password Laptop new session
The new password was hashed with scrypt and written to account.password. The token row is deleted — one-time use, enforced by deletion.
1 Submit
email
2 Mint +
hash token
3 Email
link
4 User
clicks
5 Validate
token
6 Hash new
password
7 Kill all
sessions
8 Sign in
fresh
every old key — dead
Laptop old session Phone old session Attacker stolen password Laptop new session
The hinge. Every session this user had — every other browser, every other device, the attacker's stolen one — is gone. The new password is the only key now.
1 Submit
email
2 Mint +
hash token
3 Email
link
4 User
clicks
5 Validate
token
6 Hash new
password
7 Kill all
sessions
8 Sign in
fresh
every old key — dead
Laptop old session Phone old session Attacker stolen password Laptop new session
One fresh session is issued. The user lands signed in with a new cookie — the only live key in existence.

Reset lives on the same emailAndPassword block sign-up opened in the first lesson, unlike email verification, which earned its own emailVerification block: verification is a separate subsystem, but reset is just a capability of email-and-password. Three additions light the whole flow:

  • The send callback. sendResetPassword has the same shape as last lesson’s sendVerificationEmail. Better Auth hands you { user, url, token } with the link already minted, and your one-line job is to deliver it through the Unit 7 sendEmail wrapper. The token is exposed for a caller who builds their own link, but a template that takes url doesn’t need it.
  • The expiry. Ten minutes, shorter than verification’s hour, because the stakes climb. A verification link only proves “I can read this inbox”; a reset link grants the power to change the credential, so a leak is an account takeover.
  • The session-revoke flag. revokeSessionsOnPasswordReset: true is off by default, so a reset leaves every old session alive until you turn it on. Everything else depends on it; the next section explains why.
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
minPasswordLength: 12,
sendResetPassword: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: 'Reset your password',
react: ResetPasswordEmail({ url }),
});
},
resetPasswordTokenExpiresIn: 60 * 10,
revokeSessionsOnPasswordReset: true,
},

The same seam and one-line body as the verification email: the library mints the token and builds the url, and you deliver it through the sendEmail wrapper. The template only needs the url.

emailAndPassword: {
enabled: true,
requireEmailVerification: true,
minPasswordLength: 12,
sendResetPassword: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: 'Reset your password',
react: ResetPasswordEmail({ url }),
});
},
resetPasswordTokenExpiresIn: 60 * 10,
revokeSessionsOnPasswordReset: true,
},

Ten minutes, shorter than the hour you gave the verification link. A verify link only proves you can read an inbox; a reset link can change the credential. Higher stakes earn a shorter window, so you’re choosing the tighter value over the library’s one-hour default.

emailAndPassword: {
enabled: true,
requireEmailVerification: true,
minPasswordLength: 12,
sendResetPassword: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: 'Reset your password',
react: ResetPasswordEmail({ url }),
});
},
resetPasswordTokenExpiresIn: 60 * 10,
revokeSessionsOnPasswordReset: true,
},

Off by default, turned on here. The library will not evict old sessions unless this line says so. Why that matters is the next section’s whole job.

emailAndPassword: {
enabled: true,
requireEmailVerification: true,
minPasswordLength: 12,
sendResetPassword: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: 'Reset your password',
react: ResetPasswordEmail({ url }),
});
},
resetPasswordTokenExpiresIn: 60 * 10,
revokeSessionsOnPasswordReset: true,
},

All three live inside the block sign-up opened, because reset is a capability of email-and-password, not a separate subsystem like the emailVerification block.

1 / 1

That’s the config. Now the two actions that surround it.

Step 1: the request, and the door that gives nothing away

Section titled “Step 1: the request, and the door that gives nothing away”

The request action is the fourth time you’ve written the same skeleton: parse the input with Zod, authorize (nothing to authorize here, since this is a public door), call the library, skip revalidation, return a typed Result. Only the reset-specific parts are worth stopping on.

The schema normalizes the email before validating it, .trim().toLowerCase() then the email check, the same normalization sign-up drilled, so Ada@Acme.com still resolves to the one canonical account. The mutate seam is auth.api.requestPasswordReset, and redirectTo tells Better Auth which page the emailed link should open: your /reset-password form. It works like callbackURL did for the verification link. The library appends the token to that path when it builds the URL.

app/(auth)/forgot-password/actions.ts
'use server';
import { z } from 'zod';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { ok, err, type Result } from '@/lib/result';
const forgotPasswordSchema = z.object({
email: z.string().trim().toLowerCase().pipe(z.email()),
});
export async function requestReset(
_prev: unknown,
formData: FormData,
): Promise<Result<null>> {
const parsed = forgotPasswordSchema.safeParse(
Object.fromEntries(formData),
);
if (!parsed.success) {
return err(
'validation',
'Enter a valid email address.',
z.flattenError(parsed.error).fieldErrors,
);
}
try {
await auth.api.requestPasswordReset({
body: { email: parsed.data.email, redirectTo: '/reset-password' },
headers: await headers(),
});
} catch {
return ok(null);
}
return ok(null);
}

Now the part worth slowing down for. This is the chapter’s second public door, so the enumeration reflex from sign-up applies again. Most of the user enumeration defense is already done for you: requestPasswordReset returns a uniform success whether or not the email exists; when there’s no account, no email gets sent, but the caller can’t tell the difference.

Your job is not to build enumeration safety; it’s to not undo it. The catch block above returns ok(null), exactly like the success path. You never branch the Result on whether the account existed, so the form renders one line: “If an account exists for that email, we’ve sent a reset link.” A user who mistypes their email gets no “that’s not us” feedback, which is the correct trade: a helpful error message here is an account-discovery oracle.

The leaky and safe versions differ by a few lines, and those few lines are the whole section.

try {
await auth.api.requestPasswordReset({
body: { email, redirectTo: '/reset-password' },
headers: await headers(),
});
} catch {
// Distinct response → an oracle for "which emails have accounts"
return err('not_found', 'No account with that email.');
}
return ok(null);

Rebuilds the oracle. The “no account” branch means a real address returns ok and a fake one returns not_found, so anyone can sift a list of emails for live accounts, one request at a time. The reset endpoint just became an account-discovery tool.

Step 2: the reset itself, validate the token and set the new credential

Section titled “Step 2: the reset itself, validate the token and set the new credential”

The page at /reset-password is a Client Component: it owns the form state and runs the confirm-match check as the user types. The action behind it is the fifth instance of the skeleton, with two pieces of schema worth a look.

First, newPassword carries .min(12), the mirror of a sign-in rule. There the schema used .min(1), because sign-in only checks a credential the user already chose, and re-imposing the strength floor would reject legitimate old passwords. Here you’re setting a new credential, so the full sign-up floor applies again. Same field, opposite rule for checking versus setting.

Second, the only new bit of schema in the lesson: a confirmPassword field, refined to match newPassword. If they differ, the error attaches to the confirm field and the form surfaces it before anything reaches the server.

app/(auth)/reset-password/actions.ts
'use server';
import { z } from 'zod';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { APIError } from 'better-auth/api';
import { ok, err, type Result } from '@/lib/result';
const resetPasswordSchema = z
.object({
token: z.string().min(1),
newPassword: z.string().min(12),
confirmPassword: z.string(),
})
.refine((data) => data.newPassword === data.confirmPassword, {
error: 'Passwords do not match.',
path: ['confirmPassword'],
});
export async function resetPassword(
_prev: unknown,
formData: FormData,
): Promise<Result<null>> {
const parsed = resetPasswordSchema.safeParse(
Object.fromEntries(formData),
);
if (!parsed.success) {
return err(
'validation',
'Check the highlighted fields.',
z.flattenError(parsed.error).fieldErrors,
);
}
try {
await auth.api.resetPassword({
body: {
token: parsed.data.token,
newPassword: parsed.data.newPassword,
},
headers: await headers(),
});
} catch (error) {
if (error instanceof APIError) {
return err('not_found', 'This reset link is invalid or has expired.');
}
throw error;
}
return ok(null);
}

What is auth.api.resetPassword doing with that token? You built the answer last lesson. When the request fired in step 1, a verification row appeared, using the same table and hashed-token discipline the verification lesson taught. Only two things differ:

  • The identifier is namespaced reset-password:<userId> instead of a bare email, so reset tokens and verify tokens never collide in the one shared table.
  • The expiry is ten minutes instead of an hour.

Everything else is identical: the raw token rides in the link, the hashed value sits in the row, the library hashes the incoming token and looks it up in constant time, checks the expiry, and deletes the row on success to make the link one-time-use. Same two-secret split, same bearer-token character.

The failure path collapses the same way the verify link did. A token that’s wrong, expired, or already spent gets one message, not three, since three would leak which it was: “this reset link is invalid or has expired, request a new one,” with a path back to /forgot-password. The catch above maps the thrown APIError to a single err, the way the sign-in action mapped its throws to one 'invalid-credentials'.

One detail for wiring the page: Better Auth lands a valid token on /reset-password?token=<token>, but redirects to /reset-password?error=INVALID_TOKEN when the token is already bad on arrival. The page reads searchParams: if error=INVALID_TOKEN is present, render the “invalid or expired” branch with the link back; otherwise read the token and show the form. This param is uppercase, unlike the verify endpoint’s, so read each off the real redirect rather than copying.

Why a reset kills every session (the move that makes it a reset)

Section titled “Why a reset kills every session (the move that makes it a reset)”

Start with the question the section depends on: why does anyone reset a password? The comfortable answer is “they forgot it.” The security answer is less reassuring: a reset request means the current password may already be in the wrong hands. Maybe it leaked in a breach of another site where the user reused it. Maybe it was phished. Maybe an attacker holds a live session in another browser right now. A reset flow has to be designed for that worst case, because that’s the case where it matters.

Now consider changing the password but leaving the existing sessions alive. The attacker’s cookie still works; they keep reading and acting, and the user who just reset has accomplished nothing against them. To evict them, the reset has to end every session: DELETE FROM session WHERE userId = ?, so every cookie on every device dies the instant the new password lands.

Here is the trap. Better Auth does not do this by default. Out of the box, resetPassword updates the credential and leaves every session live. You opt in with revokeSessionsOnPasswordReset: true, the flag you set two sections ago. A developer who wires the happy path and never touches that flag ships a reset that looks finished: you reset, you’re signed in, every test passes. But it’s a back door, because the attacker’s session survived. The most dangerous version of this flow is the one that runs perfectly and protects no one.

The rule, small enough to carry: session invalidation on any credential change, and you have to ask for it.

The two panels below show why. Same starting devices, same reset; the only difference is whether the flag was on.

Without invalidation revokeSessionsOnPasswordReset: false

After the reset

Ada's laptop old session still valid Ada's phone old session still valid Attacker's session has the old password still valid Ada's laptop new session just signed in

The attacker never logged out. The new password changed nothing for them.

With invalidation revokeSessionsOnPasswordReset: true

After the reset

Ada's laptop old session revoked Ada's phone old session revoked Attacker's session has the old password revoked Ada's laptop new session just signed in

Every old key is dead. The new password is the only way in.

A reset that leaves old sessions alive is not really a reset. It's a name change on a door the attacker still holds the key to.

The same principle reappears with one tweak in code you’ll write soon. When a signed-in user changes their password from account settings, every session minted under the old password must die except the one they’re currently using; you don’t want to sign someone out of the browser they’re sitting in just because they rotated their password. So change-password passes revokeOtherSessions: true, revoking everything except the current session. Reset has no current session to spare, because the user is unauthenticated, so revokeSessionsOnPasswordReset takes them all. One principle, two cases: a credential change invalidates the sessions minted under the old credential, and both are opt-in.

That leaves one product call: where does the user land? With the old sessions cleared, Better Auth issues one fresh session, so the consumer default drops the user straight in, since the click plus the new password proved control. High-stakes products bounce to /sign-in for an explicit fresh sign-in. Which you pick is a product judgment.

A quick check. The question is not “what does the lesson say” but “why is it true.”

A secure password reset must end every existing session for that user. Which of these are reasons why? Select all that apply.

By the time someone reaches for a reset, you have to assume their current password may already be sitting in an attacker’s hands.
Someone unauthorized might be holding a live session at this very moment, and swapping the password on its own would never log them out.
A fresh password buys you nothing if the cookies handed out under the old one still unlock the account.
Data-protection regulations make it mandatory for any password change to terminate all open sessions.
Clearing the old sessions lets the reset finish faster for the person doing it.
Better Auth tears the sessions down on its own, so skipping the flag is just a minor discourtesy.

The reset email, and the line only a reset needs

Section titled “The reset email, and the line only a reset needs”

This flow needs one email template, and you’ve built its twin. It lives at emails/reset-password.tsx, exports ResetPasswordEmail, takes a single { url } prop, and is rendered by the sendResetPassword callback. The React Email anatomy and the Resend pipeline are yours from earlier units, and the template follows the same one-job discipline as the verification email: one call-to-action button, a plain-text fallback URL for clients that strip the button, and an expiry note.

One element the verification email didn’t need: a line saying “if you didn’t request this, you can safely ignore it.” A reset email can land unbidden, because anyone can type someone else’s address into a forgot-password form. The recipient might never have asked for it, staring at an alarming “reset your password” message, and needs to be told that doing nothing is safe: ignoring the email leaves their password exactly as it was. The line isn’t boilerplate; it stops a confused user from clicking a link they didn’t initiate.

emails/reset-password.tsx
type ResetPasswordEmailProps = {
url: string;
};
export function ResetPasswordEmail({ url }: ResetPasswordEmailProps) {
return (
<Html>
<Body>
<Container>
<Heading>Reset your password</Heading>
<Text>Click the button below to choose a new password.</Text>
<Button href={url}>Reset password</Button>
<Text>Or paste this link into your browser:</Text>
<Text>{url}</Text>
<Text>This link expires in 10 minutes.</Text>
<Text>
If you didn't request a password reset, you can safely ignore
this email — your password won't change.
</Text>
</Container>
</Body>
</Html>
);
}

The token lives in the URL, and why that’s acceptable here

Section titled “The token lives in the URL, and why that’s acceptable here”

By now you should be uneasy: the course obsesses over keeping secrets out of reach, and here’s one sitting in a plain, clickable URL. The instinct is correct. The exposure is real, too: a token in a URL can show up in browser history, Referer headers, and server or proxy logs . The reset link is acceptable anyway because three mitigations stack:

  • Short expiry. Ten minutes, so a leaked URL is useful for only a tiny window.
  • One-time use by deletion. The row is gone the instant the reset succeeds, so a URL sitting in a log is already spent: it points at a token that no longer exists.
  • Don’t log the URL. The chapter’s same-origin Referrer-Policy posture (configured later, in a security-headers chapter) and not breadcrumbing full URLs into your error tracker keep the link out of the places it would leak.

This is the same reasoning the verification lesson gave for that link’s bearer-token character; a reset link is the same kind of bearer token with a shorter expiry and higher stakes. Some teams put the token in the URL fragment (after #), which is never sent to the server or in the Referer header. It’s a fine extra layer, but the default is defensible, so reach for it only if your threat model asks.

One last reflex you already know. The redirectTo you passed in step 1, and any post-reset destination read off the URL, is untrusted input , like every ?next= in this course. Any redirect your code performs after a reset routes through safeNext, the open-redirect guard from the sign-in lesson: same-site /… paths only, absolute and protocol-relative URLs rejected. Better Auth validates its own redirect targets against trustedOrigins; the redirect you write around it is yours to guard.

When a reset still isn’t enough: 2FA and the limits of the flow

Section titled “When a reset still isn’t enough: 2FA and the limits of the flow”

A reset proves two things: “I can read this inbox” and “I set a new password.” It is not full account recovery.

If the account has two-factor authentication enabled, the possession factor you’ll add in a later lesson, a reset alone does not get an attacker in: sign-in still demands the second factor. A leaked password, even combined with an attacker who controls the email and can intercept the reset link, still fails at the authenticator prompt. A reset rotates the thing the user knows; it never touches the thing the user has.

The flip side is the recovery gap. If the user has also lost their second factor, the reset link won’t save them. Recovery codes (covered with two-factor auth) are the intended path; without those, you’re into support-driven identity verification, outside what any auth library can do. Auth-flows-as-code can rotate a forgotten password, but it can’t vouch for who you are when every factor is gone.

One more thing sits under both endpoints: rate limits. A per-IP cap stops an attacker from harvesting reset emails across thousands of accounts; a per-email cap stops the request endpoint from flooding one inbox. Better Auth’s defaults are sane, so this works out of the box; the full dual-key wiring comes in a later chapter.

Closing: the anti-patterns that still ship

Section titled “Closing: the anti-patterns that still ship”

The watch-outs. Each is a pattern that still ships and that you can catch in a code review:

That completes the password lifecycle: sign up, sign in, verify, reset. All four held the same enumeration line and reused the verification-table primitive under a different namespace. What’s left of authentication either replaces the password (magic links, passkeys, OAuth) or layers on top of it (two-factor auth).