July 14, 2026


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.
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:
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.
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.
Picture a real user, Jane, who forgot her password. Here's exactly what happens, one step at a time.
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
}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 };
}Here's the part that's easy to miss because it happens invisibly, inside Better Auth's own code. When requestPasswordReset runs, Better Auth:
3FYc0npMeRtDh3u28lOp39PVverification), along with which user it belongs to and an expiry time (e.g. "this token stops working in 1 hour")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
sendResetPasswordfunction 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.
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.
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} />;
}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:
catch block turns that into a friendly error message| # | What happens | Where |
|---|---|---|
| 1 | Jane submits her email | Browser (form) |
| 2 | Your action calls requestPasswordReset | Server action |
| 3 | Better Auth generates a token and saves it to the DB | Inside Better Auth |
| 4 | Better Auth hands you the token via sendResetPassword | auth.ts |
| 5 | You email a link containing the token | Resend + email template |
| 6 | Jane clicks the link, lands on /reset-password/[token] | Dynamic route |
| 7 | Jane submits a new password + the token | Browser (form) |
| 8 | Better Auth looks up the token in the DB, validates it, updates the password | Inside Better Auth |
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.
await auth.api.signUpEmail({
body: {
email: userRegisterDetails.email,
password: userRegisterDetails.password,
// ...other fields
},
});await auth.api.sendVerificationOTP({
body: { email: userRegisterDetails.email, type: "email-verification" },
});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
});
}
},
}),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.
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.
This is the part beginners mix up most, so let's put them side by side.
| Email verification token | Password reset token | |
|---|---|---|
| What it looks like | Short numeric code, e.g. 482913 | Long random string, e.g. 3FYc0npMeRtDh3u28lOp39PV |
| How the user uses it | Types 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 it | The emailOTP plugin | The 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 checked | During sign-up, via verifyEmailOTP | During 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.
// ❌ 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 },
});
}(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");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 }) {}undefined inside a linkhttp://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.
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.
Whenever you're confused about a token-based flow, ask yourself these four questions in order:
requestPasswordReset or sendVerificationOTP.)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.
resetPasswordverifyEmailOTPsendResetPassword callback in auth.ts receives an already-generated, already-saved token — it doesn't create one[token]) are required whenever a value is part of the URL path, not a query string(auth)) never appear in the actual URL — don't include them in router.push or <Link href>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.