How to Build a Secure Forgot Password Flow with OTP in Better Auth
Source: Dev.to

In our previous guide we set up Better Auth with Nodemailer to handle email verification and invites. Today, we are tackling a critical feature for any production app: the “Forgot Password” flow.
While Magic Links (links that log you in or reset your password immediately) are popular, they force the user to leave your app, open their email, and click a link that opens a new tab.
Sometimes you want to keep the user right where they are. In this guide we will build an OTP (One‑Time Password) reset flow. The user enters their email, receives a 6‑digit code, and resets their password without ever closing the tab.
The Backend Config
Good news: if you followed the previous guide, your backend is already ready.
The emailOTP plugin we installed previously handles the forget-password type automatically. When we call the forgot‑password function from the client, Better Auth will trigger the sendVerificationOTP hook we defined in auth.ts with the type set to "forget-password".
Just ensure your email.ts template handles the subject line dynamically (or is generic enough) for password resets.
The Client‑Side Implementation
We need to handle two distinct stages in our UI:
- Request Stage – user enters email → system sends OTP.
- Reset Stage – user enters OTP + new password → system updates credentials.
1. The Logic Hooks
Instead of dumping 200 lines of UI code, let’s look at the core functions using authClient.
import { authClient } from "@/lib/auth-client";
import { useState } from "react";
export const usePasswordReset = () => {
const [stage, setStage] = useState("request");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
// Step 1: Send the OTP
const sendResetOtp = async (email: string) => {
setIsLoading(true);
setError(null);
const { error } = await authClient.forgetPassword.emailOtp({
email,
redirectTo: "/dashboard", // Optional: strictly for flow control
});
setIsLoading(false);
if (error) {
setError(error.message);
return false;
}
setStage("reset"); // Move to next UI stage
return true;
};
// Step 2: Validate OTP and set new password
const resetPassword = async (
email: string,
otp: string,
password: string
) => {
setIsLoading(true);
setError(null);
const { error } = await authClient.emailOtp.resetPassword({
email,
otp,
password,
});
setIsLoading(false);
if (error) {
setError(error.message);
return false;
}
return true; // Success!
};
return {
stage,
setStage,
isLoading,
error,
sendResetOtp,
resetPassword,
};
};
2. The UI Implementation
Below is a simplified UI. You can keep your Tailwind gradients for that premium feel.
The Request Form (Stage 1)
if (stage === "request") {
return (
<>
<h2>Forgot Password</h2>
<p>Enter your email to receive a verification code.</p>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
/>
<button
onClick={() => sendResetOtp(email)}
disabled={isLoading || !email}
>
{isLoading ? "Sending Code…" : "Send OTP"}
</button>
{error && <p className="error">{error}</p>}
</>
);
}
The Reset Form (Stage 2)
if (stage === "reset") {
return (
<>
<h2>Reset Password</h2>
<p>Code sent to {email}</p>
{/* OTP Input – limit to 6 numbers */}
<label>OTP Code</label>
<input
type="text"
value={otp}
onChange={(e) =>
setOtp(e.target.value.replace(/\D/g, "").slice(0, 6))
}
className="tracking-widest text-center text-lg"
placeholder="000000"
/>
<label>New Password</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<label>Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
<button
onClick={() => {
if (newPassword === confirmPassword) {
resetPassword(email, otp, newPassword);
}
}}
disabled={isLoading}
>
{isLoading ? "Resetting…" : "Set New Password"}
</button>
{/* Resend Logic */}
<button
onClick={() => sendResetOtp(email)}
className="text-sm underline mt-4"
>
Resend Code
</button>
</>
);
}
Magic Link vs. OTP: Which Should You Choose?
Better Auth supports both approaches. Below is a quick breakdown to help you decide which fits your app better.
| Feature | Magic Link | OTP |
|---|---|---|
| User Experience | Leaves the app, opens email | Stays in‑app |
| Implementation Complexity | Simple – just a link | Requires UI for code entry |
| Security | One‑click, vulnerable to email compromise | Code expires quickly, harder to phish |
| Use Cases | Quick login, low‑friction flows | Password resets, high‑security contexts |
Choose the method that aligns with your product’s UX goals and security requirements. Happy coding!
The OTP Approach (This Guide)
Pros
- Seamless Context: The user never leaves your application tab, reducing drop‑off rates.
- Mobile Friendly: Ideal for mobile apps where handling deep links (Universal Links) can be buggy or complex to set up.
- Perceived Security: Users are accustomed to 2FA codes, so entering a code feels secure.
Cons
- Friction: The user must manually copy‑paste (or type) a code.
- Typos: Mistyped codes or passwords require additional error handling.
The Magic Link Approach
Pros
- Zero Friction: One click and the user is done.
- Simple: No need for the user to understand what a “code” is.
Cons
- Context Switching: Forces the user to open a new browser window/tab.
- Email Scanners: Aggressive enterprise email scanners sometimes “click” links to check for malware, which can accidentally invalidate one‑time tokens before the user sees them.
Conclusion
By using the emailOTP plugin in Better Auth, we’ve created a password‑reset flow that keeps users engaged within our application. It’s a small UX detail, but keeping users inside your app ecosystem always leads to higher conversion and retention.
For more information, see our blog.
