Password reset
Building a secure forgot-password flow with Better Auth, where an emailed reset link rotates the credential and revokes every existing session.
The last three lessons all quietly assumed the same thing: that 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 every one of them broke the moment the user typed the wrong password and couldn’t fix it. This lesson handles that moment.
That moment is also the most security-sensitive of the four, because a password reset is the one flow in your entire app that is 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, and that’s the whole problem, so let’s establish a new way in.” Build it naively and you haven’t built a convenience; you’ve built a back door with a polite interface. By the end of this 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. That last part is the one juniors skip.
Three senior questions hide behind what looks like a two-screen form:
- What’s the call shape, and 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.
- What’s in the link, how long does it live, and why is that shorter than the verification link? It uses the same token machinery as last lesson, but with a tighter expiry, on purpose.
- What’s the one side effect on success that separates a secure reset from a back door, and why is it non-negotiable? This is the spine of the lesson, and it does not happen for free.
A few related topics sit just outside this lesson’s edges. 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-limit mechanics that sit on every endpoint below, each get their own home. They’re 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”Before any code, hold the whole flow in your head. 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, so here’s the end-to-end walk with the failure mode named next to the mechanic:
- Request. The user submits their email on
/forgot-password. The action callsauth.api.requestPasswordReset. Better Auth mints a random token, stores its hash in averificationrow, and fires yoursendResetPasswordcallback. Failure mode: leaking whether that email belongs to a real account. - Uniform response. The form renders “if an account exists, we’ve emailed a link,” the exact 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.
- 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. - 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. - 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.
- Land. The user is signed in fresh with one new session, or bounced to
/sign-infor high-stakes products, with a one-time success message.
Read that list again and notice where the weight sits. Steps 1 through 4 are wiring you’ve essentially done three times now. Step 5 is the only genuinely new idea in this lesson, and it’s the reason a secure reset is harder than “email a link to whoever asks.” Hold onto it.
Here’s the same flow as a sequence you can scrub through. Watch what happens at the invalidation step: the dying sessions are the heart of the lesson in one frame.
hash token
link
clicks
token
password
sessions
fresh
/forgot-password and
pressed send. The request leg begins.
hash token
link
clicks
token
password
sessions
fresh
verification row. The raw token
never touches the database.
hash token
link
clicks
token
password
sessions
fresh
sendResetPassword callback delivered the link
through Resend. The library is now waiting — nothing happens
until the user acts.
hash token
link
clicks
token
password
sessions
fresh
hash token
link
clicks
token
password
sessions
fresh
hash token
link
clicks
token
password
sessions
fresh
account.password. The token row is deleted —
one-time use, enforced by deletion.
hash token
link
clicks
token
password
sessions
fresh
hash token
link
clicks
token
password
sessions
fresh
That’s the whole flow. Everything from here slots into one of those six steps.
Turning reset on: the config
Section titled “Turning reset on: the config”Reset lives on the same emailAndPassword block that sign-up opened back in the first lesson. That’s a deliberate contrast with email verification, which earned its own emailVerification block: verification is a separate subsystem, but reset is just a capability of email-and-password, so it nests right inside the block you already have. Three additions light the whole flow.
The first is the send callback. sendResetPassword has the exact same shape as the sendVerificationEmail callback from last lesson. 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. You generate no token and build no URL; the library did both. The token is handed to you too, for the rare caller who builds their own link, but a template that takes url doesn’t need it.
The second is the expiry. Last lesson the verification link lived for an hour, and you agreed with Better Auth’s default. Here you go shorter: ten minutes. The reasoning is a ladder of stakes. A verification link only proves “I can read this inbox,” so if one leaks, the worst case is that someone gets an account verified slightly early. A reset link grants the power to change the credential, so if one leaks, that’s an account takeover. Higher stakes earn a shorter window. Better Auth’s own default here is the same one-hour window verification used, so this is you making a choice, not accepting one.
The third addition is the one everything else depends on, and I’ll name it here and then deliberately hold it for the section it deserves: revokeSessionsOnPasswordReset: true. It is off by default, which means a reset leaves every old session alive until you turn it on. This is the single most important line in the whole flow. For now, know that it’s true, and know that without it Better Auth will not evict old sessions.
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 as the verification email, and the same one-line body: the library mints the token and builds the url, and you deliver it through the sendEmail wrapper. You never touch the token here, because 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, deliberately 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. Better Auth’s own default here is an hour, so you’re choosing the tighter value.
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, and the reason that matters is the next section’s entire job. For now, register that it’s deliberately turned on.
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,},Note where all three live: inside the block sign-up opened, because reset is a capability of email-and-password, not a separate subsystem the way email verification earned its own emailVerification block.
That’s three lines of config, including the one to keep an eye on. Now to 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 (here there’s nothing to authorize, because this is a public door), call the library, skip revalidation, return a typed Result. By now this is muscle memory, so I’ll let the code speak and only stop on the parts that are specific to reset.
The schema normalizes the email before validating it: .trim().toLowerCase() and then the email check. This is the same normalization the sign-up lesson drilled, so Ada@Acme.com with a stray space and stray capitals 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. This works the same way callbackURL set the landing page for the verification link. The library appends the token to that path when it builds the URL.
'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, even though you already know the idea. This is the chapter’s second brand-new public door (the resend button and verify endpoint were the first), so the enumeration reflex you built in the sign-up lesson applies again: every entry point answers “does this email exist?” with the same shape. The good news is that user enumeration defense is mostly already done for you here. 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.
So your job in this action is not to build enumeration safety; it’s to not undo it. Look at the catch block above: it returns ok(null), exactly like the success path. You never branch the Result on whether the account existed, and the form never renders “no account with that email.” It renders one calm, identical line: “If an account exists for that email, we’ve sent a reset link.”
There’s a real cost to that, and it’s worth naming on purpose rather than sleepwalking past it: a user who mistypes their email gets no “that’s not us” feedback, since the form looks like it worked either way. That’s the correct trade. The same friendly-versus-opaque tension you weighed at sign-up resolves the same way at this door, and for the same reason: a helpful error message here is an account-discovery oracle.
To make the failure concrete, here’s the difference between a leaky action and a safe one. The two tabs differ by a few lines, and those few lines are the entire lesson of this 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 now sift a list of emails for live accounts, one request at a time. The reset endpoint just became an account-discovery tool.
try { await auth.api.requestPasswordReset({ body: { email, redirectTo: '/reset-password' }, headers: await headers(), });} catch { return ok(null);}return ok(null);Tells the attacker nothing. Real or fake, the action returns the same ok, and the form renders one line: “If an account exists, we’ve sent a link.” The user who mistyped their email pays a small cost; the attacker gets no signal at all. That’s the trade, chosen deliberately.
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 interactive form state (the new password, the confirm field) and runs the confirm-match check as the user types, which is exactly the kind of work that belongs on the client. The action behind it is the fifth instance of the skeleton, with two pieces of schema worth a look.
The first is that newPassword carries .min(12). This is the deliberate mirror of a rule from the sign-in lesson. There, the sign-in schema used .min(1) on the password, because sign-in only checks a credential the user already chose, so re-imposing the strength floor would just reject legitimate old passwords with a confusing error. Here you’re doing the opposite: you’re setting a new credential, so the full sign-up floor applies again. Same field, opposite rule for checking versus setting, and now you’ve seen both ends of it.
The second is the only genuinely new bit of schema in the whole 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.
'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);}Now, what is auth.api.resetPassword actually doing when it gets that token? You already know the answer, because you built it last lesson. When the request fired in step 1, a verification row appeared, using the same table and the same hashed-token discipline the verification lesson taught. There are only two differences, and they’re both small:
- The
identifieris namespacedreset-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, and worth restating only so you trust it: 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, which makes the link one-time-use. That’s the same two-secret split, the same bearer-token character, the same reasons. You already understand all of it; the only differences are the namespace and the expiry, so there’s nothing here to re-derive.
The failure path collapses the same way it did for the verify link. A token that’s wrong, expired, or already spent does not get three different error messages, because that would leak which of the three it was. All three collapse to one outcome: “this reset link is invalid or has expired, request a new one,” with a path back to /forgot-password. That’s what the catch above does: it maps the thrown APIError to a single err, the same way the sign-in action mapped its throws to one 'invalid-credentials'.
One detail for when you wire the page itself: the reset link Better Auth generates lands the user on /reset-password?token=<token> for a valid token, but redirects to /reset-password?error=INVALID_TOKEN when the token is already bad on arrival. So 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 here, so don’t assume it matches the casing of the verify endpoint’s param; read each off the real redirect.
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)”This is the section the whole lesson has been walking toward. Everything before it was setup; everything after is consequence.
Start with a question the rest of this section depends on: why does anyone reset a password? The comfortable answer is “they forgot it.” The honest answer, from a security stance, is less reassuring: a reset request means the current password may already be in the wrong hands. Maybe it leaked in a breach of some other site where the user reused it. Maybe it was phished. Maybe an attacker is, right now, holding a live session in another browser. A reset flow has to be designed for that worst case, because that’s the case where it matters.
Now sit with what happens if you change the password but leave the existing sessions alive. The attacker’s cookie still works. They keep reading, keep acting, keep doing whatever they were doing, and the legitimate user, who just went to the trouble of resetting, has accomplished nothing against them. The reset changed nothing for the attacker. To actually evict them, the reset has to do the one thing that kills their access: end every session. Concretely, that’s DELETE FROM session WHERE userId = ?, so every cookie out there, on every device, dies the instant the new password lands.
And here is the trap, the reason this gets its own section instead of a config bullet: Better Auth does not do this by default. Out of the box, resetPassword updates the credential and leaves every existing session live. You opt in with revokeSessionsOnPasswordReset: true, the flag you set two sections ago. So 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 silently a back door, because the attacker’s session survived the reset. The most dangerous version of this flow is the one that runs perfectly and protects no one. You configure a single line, but you have to understand why, because the same discipline returns later in code you write yourself.
Here’s the rule, small enough to carry: session invalidation on any credential change, and you have to ask for it.
To see why “ask for it” carries so much weight, look at the two panels below. Same starting devices, same reset; the only difference is whether the flag was on.
revokeSessionsOnPasswordReset: false After the reset
The attacker never logged out. The new password changed nothing for them.
revokeSessionsOnPasswordReset: true After the reset
Every old key is dead. The new password is the only way in.
This same principle reappears in code you’ll write by hand soon, with one tweak. When a signed-in user changes their password from account settings, the same rule applies, since every session minted under the old password must die, but with one exception: the session they’re currently using. You don’t want to sign someone out of the very 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, which is the entire premise, so revokeSessionsOnPasswordReset takes them all. Two cases, one principle: a credential change invalidates the sessions minted under the old credential. The fact that it’s opt-in in both places is exactly why it’s worth understanding rather than memorizing.
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, signed in, because the click plus the new password proved control. High-stakes products instead bounce to /sign-in and force an explicit fresh sign-in. Which one you pick is a judgment about the product, not a default to accept blindly.
A quick check before moving on. 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.
revokeSessionsOnPasswordReset: true), a deliberate security choice rather than free behavior — leave it off and the attacker’s session sails right through the reset. The “regulation requires it” and “it’s faster” options are simply wrong: invalidation is about evicting whoever already had access, not compliance or speed.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 exactly one email template, and you’ve already built its twin. It lives at emails/reset-password.tsx, exports ResetPasswordEmail, takes a single { url } prop, and gets rendered by the sendResetPassword callback you wired in the config. The React Email anatomy and the Resend send pipeline are yours from earlier units, and the template follows the same one-job-only discipline as the verification email: a single call-to-action button, a plain-text fallback URL for clients that strip the button, and an expiry note.
There’s one element here that the verification email didn’t really need: a line that says, in effect, “if you didn’t request this, you can safely ignore it.” It’s worth understanding why this email specifically needs it. A reset email can land in an inbox unbidden, because anyone can type someone else’s address into a forgot-password form and trigger one. So the recipient might be a person who never asked for it, looking at an alarming “reset your password” message. That person needs to be told the thing that’s both true and reassuring: doing nothing is safe. Ignoring the email leaves their password exactly as it was, since a reset email on its own changes nothing. This line isn’t boilerplate; it’s a security-UX element that stops a confused user from clicking a link they didn’t initiate, and sets the right expectation about what the email does and doesn’t do.
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 a little uneasy. The whole course obsesses over keeping secrets out of reach, and here’s a secret sitting in a plain, clickable URL. That instinct is correct, so let’s answer it head-on rather than wave it away.
The exposure is real. A token in a URL can show up in browser history, in Referer headers, and in server or proxy logs . Those are genuine places a URL can leak. The reset link is acceptable anyway because three mitigations stack on top of each other:
- Short expiry. Ten minutes. The window in which a leaked URL is still useful is tiny.
- 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-Policyposture (configured later, in a security-headers chapter) and the discipline of not breadcrumbing full URLs into your error tracker keep the link out of the places it would otherwise leak.
If this argument feels familiar, that’s because it is the exact reasoning the verification lesson gave for that link’s bearer-token character. A reset link is the same kind of bearer token, just with a shorter expiry and higher stakes. Some teams go a step further and put the token in the URL fragment, the part after #, because fragments are never sent to the server or in the Referer header. It’s a fine extra layer, but the default is already defensible, so reach for it only if your threat model asks.
One last reflex, and it’s one you already know. The redirectTo you passed in step 1, and any post-reset destination you read off the URL, is untrusted input , exactly 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, with 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’s worth being honest about what that does not cover, because the common misconception is that “reset” equals “full account recovery.” It doesn’t.
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. After the reset, sign-in still demands the second factor. This is the payoff of layered defense in one sentence: a leaked password, even combined with an attacker who has somehow gained control of 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, and without those, you’re into support-driven identity verification, which lives outside what any auth library can do for you. Knowing where that boundary sits is part of the senior picture: 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 quietly under both reset endpoints: rate limits. Both requestPasswordReset and resetPassword belong behind limits. A per-IP cap stops an attacker from harvesting reset emails across thousands of accounts, and a per-email cap stops the request endpoint from being abused to flood one person’s inbox. Better Auth’s built-in defaults are sane, so this works out of the box; the full dual-key wiring comes in a later chapter. It’s named here and built there.
Closing: rebuild the flow, and the anti-patterns that still ship
Section titled “Closing: rebuild the flow, and the anti-patterns that still ship”You held the whole flow in your head at the start of this lesson; now rebuild it from memory. Drag these six steps into order, from the email request to the moment the user lands with every old session dead.
Order the six steps of a password reset, from the user asking for a link to landing back in the app. Drag the items into the correct order, then press Check.
verification row, and emails the link. Finally, the watch-outs. Each one is a pattern that still ships in real systems and that you can catch in a code review:
You’ve now built the complete password lifecycle: sign up, sign in, verify, reset. Every one of those four flows held the same enumeration line, and every one leaned on the same verification-table primitive wearing 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). The password machinery is done; the rest is variation on a foundation you now understand.
External resources
Section titled “External resources”The sendResetPassword callback ({ user, url, token }), resetPasswordTokenExpiresIn, the revokeSessionsOnPasswordReset opt-in, and the requestPasswordReset / resetPassword API surface.
How session revocation works, and how it composes with revokeOtherSessions for the signed-in change-password sibling.
The one call-to-action component the reset template is built around.
The canonical reference for secure-reset properties: enumeration closure, short expiry, token handling, session invalidation.