Implementing Forgot Password & Reset Password with Resend
In the previous article, we implemented secure user authentication using JWT Access Tokens and Refresh Tokens. Users can now register, log in, and access protected resources.
However, authentication systems must also handle a common real-world scenario—users forgetting their passwords.
Instead of creating a new account, users should be able to securely reset their existing password.
In this article, we'll build a complete password recovery workflow using Resend, secure reset tokens, PostgreSQL, and React.
Why a Password Reset Flow?
Passwords are intentionally stored as one-way hashes using bcrypt, which means they cannot be recovered—not even by the application itself.
Because of this, the only secure solution is to let users create a new password after verifying their identity.
The password reset process should:
- Verify that the email belongs to a registered user.
- Generate a secure, unique reset token.
- Expire the token after a limited time.
- Deliver the reset link through email.
- Allow password updates only after validating the token.
This approach keeps user accounts secure while providing a smooth recovery experience.
Forgot Password Flow
The complete workflow is shown below.
User clicks "Forgot Password"
│
▼
Enter Registered Email
│
▼
Backend verifies email
│
▼
Generate Secure Reset Token
│
▼
Store Token & Expiry in Database
│
▼
Send Reset Link via Resend
│
▼
User receives Email
│
▼
Clicks Reset Link
│
▼
Reset Password Page
│
▼
Submit New Password
│
▼
Validate Token
│
▼
Hash New Password
│
▼
Update Database
Why I Chose Resend
To deliver password reset emails, I chose Resend instead of configuring a traditional SMTP server.
Although SMTP works perfectly well, it often requires:
- SMTP host configuration
- Port configuration
- App passwords
- TLS configuration
- Provider-specific settings
Resend provides a much simpler developer experience.
Advantages of Resend
- Simple REST API
- Excellent Node.js SDK
- Reliable email delivery
- Clean documentation
- Easy integration
- No SMTP configuration required
This allowed me to focus on the authentication workflow instead of managing email server configuration.
Generating a Secure Reset Token
Once the backend confirms that the email exists, it generates a unique reset token.
I used:
const resetToken = crypto.randomUUID();
This creates a cryptographically secure UUID that is extremely difficult to guess.
The token is then assigned an expiration time.
const resetTokenExpiry =
new Date(Date.now() + 30 * 60 * 1000);
In this project, the reset link remains valid for 30 minutes.
Storing the Reset Token
Instead of sending the token directly without tracking it, the application stores both:
- reset_token
- reset_token_expiry
inside PostgreSQL.
Users Table
Email
Password Hash
Reset Token
Reset Token Expiry
These fields are later used to verify whether the reset request is still valid.
Repository Layer
The repository is responsible only for updating the database.
It stores:
- reset token
- expiration timestamp
for the corresponding user.
const saveRefreshToken = async (userId, refresh_token) => {
await pool.query(`UPDATE users SET refresh_token= $1 where id = $2`, [
refresh_token,
userId,
]);
};
Because database logic is isolated inside the repository, the service layer remains focused only on business logic.
Service Layer
The service performs the complete Forgot Password workflow.
It is responsible for:
- Finding the user by email.
- Generating a secure token.
- Setting an expiration time.
- Saving both values in PostgreSQL.
- Constructing the password reset URL.
- Sending the email using Resend.
const forgotPassword = async (userEmail) => {
const user = await userRepository.findByEmail(userEmail);
if (!user) {
throw new Error("user not found!!");
}
const resetTokenExpiry = new Date(Date.now() + 30 * 60 * 1000);
const reset_token = crypto.randomUUID();
await userRepository.resetPassword(reset_token, resetTokenExpiry, userEmail);
const resetUrl = `${process.env.FRONTEND_URL}/auth/reset-password/${reset_token}`;
await emailService.sendEmail({
from: "onboarding@resend.dev",
to: userEmail,
subject: "Reset Your Password",
text: `Click here to reset your password: ${resetUrl}`,
});
return {
success: true,
message: "Password reset token generated successfully",
};
};
Keeping this logic inside the service makes the controller extremely lightweight.
Email Service
Rather than sending emails directly from the authentication service, I created a dedicated Email Service.
This follows the Single Responsibility Principle, making the authentication service responsible only for authentication.
The Email Service handles:
- Initializing the Resend client.
- Preparing the email.
- Sending the email.
- Handling delivery errors.
const { Resend } = require("resend");
const resend = new Resend(process.env.RESEND_API_KEY);
const sendEmail = async ({ from, to, subject, text }) => {
try {
const response = await resend.emails.send({
from,
to,
subject,
html: `
<div style="font-family:sans-serif">
<p>${text}</p>
</div>
`,
});
console.log("Resend response:", response);
return response;
} catch (err) {
console.error("Resend Error:");
console.error(err);
throw err;
}
};
module.exports = { sendEmail };
Because the Email Service is reusable, future features such as:
- Email Verification
- Welcome Emails
- Account Notifications
can all use the same implementation.
Sending the Reset Link
After generating the reset token, the backend creates a URL pointing to the frontend.
https://your-app.com/auth/reset-password/<token>
The user receives an email similar to:
Reset Your Password
Click the link below to reset your password.
https://your-app.com/auth/reset-password/<token>
Reset Password Flow
Clicking the email redirects the user to the Reset Password page.
The frontend extracts the token from the URL.
/auth/reset-password/:token
The user enters:
- New Password
- Confirm Password
The frontend then submits:
POST /reset-password
along with the token.
Backend Validation
Before updating the password, the backend verifies:
✅ Does the token exist?
✅ Has the token expired?
If either validation fails, the request is rejected.
This prevents attackers from reusing old reset links.
Updating the Password
Once the token is verified:
- Hash the new password using bcrypt.
- Update the password hash.
- Remove the reset token.
- Remove the expiration timestamp.
The token is deleted immediately after a successful password reset.
This guarantees that every reset link can be used only once.
Security Measures
Several security practices are implemented throughout the password reset workflow.
✅ Secure UUID token generation
✅ Token expiration (30 minutes)
✅ Password hashing using bcrypt
✅ Single-use reset links
✅ Email verification before reset
✅ Invalid token rejection
These practices significantly reduce the risk of unauthorized password changes.
Testing the Forgot Password Flow
Request
POST /forgot-password
{
"userEmail":"sriya@gmail.com"
}
Response
{
"success": true,
"message": "Password reset token generated successfully"
}
The user receives a reset email.
Testing the Reset Password Flow
Request
POST /reset-password
{
"token":"<reset-token>",
"password":"NewPassword123"
}
Response
{
"success": true,
"message":"Password updated successfully"
}
Challenges Faced During Development
Implementing this feature wasn't completely straightforward.
Some of the challenges I encountered included:
- SMTP IPv6 connection failures on Render.
- Migrating from Nodemailer to Resend.
- Missing environment variables during deployment.
- Reset links returning Vercel 404 errors.
- Debugging frontend routing after deployment.
Resolving these issues helped me gain a much deeper understanding of cloud deployment, environment management, and production debugging.
Summary
In this article we implemented:
- Forgot Password API
- Secure reset token generation
- Token expiration
- Database token storage
- Email delivery using Resend
- Reset Password API
- Password update with bcrypt
- Single-use reset links
Together, these features create a secure and user-friendly password recovery workflow commonly used in production applications.
What's Next?
The authentication system is now functionally complete.
In the next article, we'll implement Logout, understand why Refresh Tokens should be invalidated, and finally deploy the complete application to Render and Vercel, along with the production issues I encountered and how I resolved them.
Continue Reading Part 5 - https://dev.to/t_sriya_2af6abc7e8d4e87da/part-5-building-an-authentication-system-from-scratch-5h7
Live App: https://auth-flow-five-iota.vercel.app/auth/
Backend API: https://auth-flow-backend-1v2h.onrender.com/
github url: https://github.com/sriyaT/Auth-Flow
Connect : LinkedIn : https://www.linkedin.com/in/t-sriya-b4234510a/, github : https://github.com/sriyaT
Author: Sriya T.



Top comments (0)