See all blogs

July 14, 2026

Understanding Forgot Password, Reset Password, and Email Verification in Better Auth (A Beginner's Guide)

Understanding Forgot Password, Reset Password, and Email Verification in Better Auth (A Beginner's Guide)Understanding Forgot Password, Reset Password, and Email Verification in Better Auth (A Beginner's Guide)

This guide assumes you already have Better Auth wired up with Prisma and Resend in your Next.js app. If you haven't done that part yet, see Set Up Better Auth with Prisma ORM and Postgres in Next.js first — this guide picks up right where that one ends, and focuses purely on the password-reset and email-verification flows.

backstory

If you're new to authentication, "forgot password" and "email verification" can feel like magic — a link shows up in someone's inbox, they click it, and somehow the app knows exactly who they are and lets them do something sensitive (reset a password, confirm an email). It isn't magic. It's a handful of small, predictable steps involving a token: a random, hard-to-guess piece of text that acts like a one-time key.

The confusing part for most beginners — and the part we'll spend the most time on — is that there isn't just one token in an auth system. There are (at least) two completely separate tokens doing two completely different jobs:

  1. A token used to verify someone owns an email address (used at sign-up)
  2. A token used to prove someone requested a password reset (used at forgot-password)

They look similar, they both get emailed to the user, and they both get "checked against the database" — but they are not the same token, they don't live in the same place, and mixing them up in your head is the single most common source of confusion when building this feature. This guide walks through both, side by side, so the difference becomes obvious.


the big picture, in one sentence

A token is just a random string that your server creates, saves to the database, emails to the user, and then — later — checks against what it saved, to prove "yes, this specific person, at this specific time, is who they say they are."

Every flow in this guide is a variation of that same idea. Once that sentence makes sense, everything else is just details.


part 1: how "forgot password" works, step by step

Picture a real user, Jane, who forgot her password. Here's exactly what happens, one step at a time.

step 1: Jane fills in the forgot-password form

She types her email into a form and clicks "Send Reset Link." Nothing has happened on the server yet — this is just Jane's browser.

async function onSubmit(data: ForgotPasswordUserSchemaTypes) {
  setIsLoading(true);
  const result = await sendForgotPasswordToken(data);
  // ...show a toast based on result.success
}

step 2: your server asks Better Auth to start a reset

The form calls a server action — code that runs on your server, not in Jane's browser — which asks Better Auth to begin the reset process.

export async function sendForgotPasswordToken(
  forgotPasswordDetail: ForgotPasswordUserSchemaTypes,
) {
  const data = await auth.api.requestPasswordReset({
    body: {
      email: forgotPasswordDetail.email,
    },
  });
  return { success: true, data, error: null };
}

step 3: Better Auth generates and saves a token (this is the important part)

Here's the part that's easy to miss because it happens invisibly, inside Better Auth's own code. When requestPasswordReset runs, Better Auth:

  1. Looks in your database for a user with that email
  2. If found, generates a random token — think of it as a long, unguessable string like 3FYc0npMeRtDh3u28lOp39PV
  3. Saves that token into your database, in a table Better Auth manages (commonly called verification), along with which user it belongs to and an expiry time (e.g. "this token stops working in 1 hour")
  4. Calls a function you wrote, handing you that same token, so you can email it to the user

That fourth point is where your own code comes back into the picture:

emailAndPassword: {
  enabled: true,
  async sendResetPassword({ user, token }) {
    await sendPasswordResetEmail({
      to: user.email,
      url: `${process.env.NEXT_PUBLIC_APP_URL}/reset-password/${token}`,
    });
  },
},

Notice: you did not create this token. Better Auth created it and already saved it to the database before calling this function. Your only job here is to take the token you were handed and build a link with it.

Beginner note: it's tempting to think "the email-sending code is where the token gets made." It isn't. By the time your sendResetPassword function runs, the token already exists in your database. Your function's only responsibility is to email it — not to generate it, not to save it.

step 4: the email goes out, with the token hidden inside a link

Your email template receives the finished link as a single prop and just displays it as a button:

<Link href={resetLink} className="cta-button">
  Reset My Password
</Link>

At this point, Jane's inbox has an email with a button pointing to something like:

https://yourapp.com/reset-password/3FYc0npMeRtDh3u28lOp39PV

The token isn't a separate visible thing in the email — it's just the last part of that link's path.

step 5: Jane clicks the link, Next.js routes her to a dynamic page

Because the token is part of the URL path (not a ?query=string), your page folder has to be dynamic — meaning its name is wrapped in square brackets so Next.js knows "this part of the URL can be anything":

app/(auth)/reset-password/[token]/page.tsx

Beginner note on the parentheses: (auth) with round brackets is a route group — it's purely for organizing your files and never shows up in the actual URL. [token] with square brackets is a dynamic segment — it does show up in the URL, and Next.js captures whatever value sits there. So the real, working URL is /reset-password/3FYc0npMeRtDh3u28lOp39PV, never /auth/reset-password/....

Your page reads that captured value and hands it to your form:

export default async function ResetPasswordPage({
  params,
}: {
  params: Promise<{ token: string }>;
}) {
  const { token } = await params;
  return <ResetPasswordForm token={token} />;
}

step 6: Jane types a new password, and the token gets checked

Jane fills in "New Password" and "Confirm Password," then submits. Now, for the first time, the token gets compared against the database — this is the moment of truth:

export async function resetPassword(formData: {
  newPassword: string;
  token: string;
}) {
  const data = await auth.api.resetPassword({
    body: {
      newPassword: formData.newPassword,
      token: formData.token,
    },
  });
  return { success: true, data, error: null };
}

Inside auth.api.resetPassword, Better Auth:

  1. Takes the token Jane's link carried
  2. Looks it up in the same table it saved it to back in step 3
  3. Checks: does this token exist? Has it expired? Has it already been used once before?
  4. If everything checks out — updates Jane's password, and destroys the token so it can never be used again
  5. If anything's wrong — rejects the request, and your catch block turns that into a friendly error message

the whole forgot-password flow, summarized

#What happensWhere
1Jane submits her emailBrowser (form)
2Your action calls requestPasswordResetServer action
3Better Auth generates a token and saves it to the DBInside Better Auth
4Better Auth hands you the token via sendResetPasswordauth.ts
5You email a link containing the tokenResend + email template
6Jane clicks the link, lands on /reset-password/[token]Dynamic route
7Jane submits a new password + the tokenBrowser (form)
8Better Auth looks up the token in the DB, validates it, updates the passwordInside Better Auth

part 2: how email verification works (and why its token is different)

Now let's look at sign-up verification — the flow where a brand-new user has to prove they own the email address they signed up with. It follows the same shape as forgot-password (generate → save → send → check), but it's a completely separate mechanism with its own token.

step 1: someone signs up

await auth.api.signUpEmail({
  body: {
    email: userRegisterDetails.email,
    password: userRegisterDetails.password,
    // ...other fields
  },
});

step 2: your code asks Better Auth to send a verification code

await auth.api.sendVerificationOTP({
  body: { email: userRegisterDetails.email, type: "email-verification" },
});

step 3: Better Auth generates a 6-digit OTP and saves it

This is the key difference to notice. Instead of a long random string embedded in a URL, this flow generates a short numeric code — usually 6 digits, like 482913 — and saves that to the database, scoped specifically to "email-verification".

emailOTP({
  expiresIn: 600, // 10 minutes
  allowedAttempts: 3,
  async sendVerificationOTP({ email, otp, type }) {
    if (type === "email-verification") {
      await sendVerificationEmail({
        to: email,
        token: otp, // a 6-digit code, not a long random string
      });
    }
  },
}),

step 4: the user types the code back in (or clicks a link containing it)

Unlike the reset-password link, which the user just clicks, an OTP is usually something the user types into a form — though some apps also support clicking a link with the code baked into it as a query param.

step 5: the code gets checked against the database

const result = await auth.api.verifyEmailOTP({
  body: { email: data.email, otp: data.code },
});

Better Auth looks up the 6-digit code you submitted, checks it matches what it saved for that email under "email-verification", confirms it hasn't expired or been used up (allowedAttempts: 3 limits guessing), and if it's valid, marks the user's emailVerified field as true.


why these two tokens are not the same thing

This is the part beginners mix up most, so let's put them side by side.

Email verification tokenPassword reset token
What it looks likeShort numeric code, e.g. 482913Long random string, e.g. 3FYc0npMeRtDh3u28lOp39PV
How the user uses itTypes it into a form (or clicks a link with it as a query param)Clicks a link with it embedded in the URL path
Which Better Auth feature generates itThe emailOTP pluginThe core requestPasswordReset / resetPassword methods
What it proves"I own this email address""I am the same person who just clicked 'forgot password', within the last hour"
When it's checkedDuring sign-up, via verifyEmailOTPDuring password reset, via resetPassword

They both get saved to a database table and later compared — that shared pattern is what makes them feel like the same idea. But under the hood, they're generated by different plugins, stored under different identifiers/purposes, shaped completely differently (6 digits vs. a long string), and checked by completely different functions. A password-reset token will never be accepted by verifyEmailOTP, and an OTP code will never be accepted by resetPassword — they're not interchangeable in any way.


common beginner mistakes (and how to avoid them)

mistake 1: trying to use the token before it exists

// ❌ wrong — token doesn't exist yet at this point
export async function sendForgotPasswordToken(data) {
  await auth.api.requestPasswordReset({
    body: {
      email: data.email,
      redirectTo: `${authBaseUrl}/reset-password/${token}`, // token is undefined here!
    },
  });
}

The token doesn't exist until after requestPasswordReset runs internally — you can't reference it before calling that function, because Better Auth is the one generating it. The token only becomes available to you inside the sendResetPassword callback in auth.ts, one step later.

// ✅ correct
export async function sendForgotPasswordToken(data) {
  await auth.api.requestPasswordReset({
    body: { email: data.email },
  });
}

mistake 2: forgetting that (folder) route groups don't appear in the URL

// ❌ this 404s, because "auth" was never really part of the URL
router.push("/auth/sign-in");

If your file lives at app/(auth)/sign-in/page.tsx, the round brackets mean the real URL is /sign-in, not /auth/sign-in.

// ✅ correct
router.push("/sign-in");

mistake 3: using String instead of string in TypeScript

// ❌ String (capital S) is a wrapper object type, almost never what you want
function ResetPasswordForm({ token }: { token: String }) {}
// ✅ string (lowercase) is the actual primitive type
function ResetPasswordForm({ token }: { token: string }) {}

mistake 4: an environment variable evaluating to undefined inside a link

http://undefined/reset-password/3FYc0npMeRtDh3u28lOp39PV

If you ever see the literal word undefined inside a generated link, it means a template string used an environment variable that wasn't actually set:

url: `${process.env.NEXT_PUBLIC_APP_URL}/reset-password/${token}`,

Check three things: the variable actually exists in your .env file, you restarted your dev server after adding it (Next.js only reads .env at startup), and the variable name is spelled exactly right.

mistake 5: revealing whether an email exists in your database

It's tempting to show a clear "no account found" message when someone requests a reset for an email that isn't registered. Resist that instinct in a public-facing app. If your form's response changes depending on whether the email exists, anyone can use that difference to build a list of every registered email on your platform — this is called user enumeration, and it's a well-known security weakness. The safer, standard pattern is to always show the same message:

"If an account exists with that email, we've sent a reset link."

This confirms the action happened without confirming whether the account exists.


the mental model to keep in your head

Whenever you're confused about a token-based flow, ask yourself these four questions in order:

  1. Where does the token get generated and saved? (Usually inside Better Auth, in response to some action like requestPasswordReset or sendVerificationOTP.)
  2. How does the token travel to the user? (Embedded in a URL, or typed in as a code — this decides whether your route needs to be dynamic.)
  3. What does the user do to send it back? (Click a link, or submit a form with the code — this decides what your form/page needs to collect.)
  4. Where does the token get checked, and what happens if it's valid or invalid? (A specific Better Auth method — resetPassword, verifyEmailOTP, etc. — looks it up, validates expiry/usage, and either succeeds or throws.)

Every "forgot password," "verify email," or "magic link" feature you'll ever build follows this exact same four-question shape — only the specific token format and Better Auth method changes.


final checklist

  • Understand that a token is just a random value: generated → saved to DB → emailed → later compared against the DB
  • Forgot-password uses a long random string, delivered as part of a URL path, checked by resetPassword
  • Email verification uses a short numeric OTP, typically typed in by the user, checked by verifyEmailOTP
  • These are two separate mechanisms with separate tokens — never assume one can substitute for the other
  • Your sendResetPassword callback in auth.ts receives an already-generated, already-saved token — it doesn't create one
  • Dynamic route segments ([token]) are required whenever a value is part of the URL path, not a query string
  • Route groups ((auth)) never appear in the actual URL — don't include them in router.push or <Link href>
  • Avoid revealing whether an email is registered in your forgot-password response, to prevent user enumeration

conclusion

The reason token-based auth flows feel confusing at first is that a lot of the important work happens invisibly, inside a library like Better Auth — the actual generating and saving of the token isn't something you write yourself, so it's easy to assume it's happening somewhere it isn't. Once you separate the flow into its four honest steps — generate, save, send, check — and keep firmly in mind that email-verification and password-reset are two entirely different tokens serving two entirely different purposes, the rest is just wiring: forms, server actions, and a couple of email templates.

resources

  • Better Auth documentation
  • Better Auth — Email & Password plugin
  • Better Auth — Email OTP plugin
  • Related: Set Up Better Auth with Prisma ORM and Postgres in Next.js

See all blogs