MERN Stack Authentication: Building a Secure System from Scratch (Part 5)
Implementing password reset for frontend and backend, and wrapping up the series
Introduction
Hello there! Today, we are going to conclude our authentication series by discussing the final essential feature of any authentication system: password resetting. What if a user forgets their password? Let's make sure there's a friendly way for them to get back on track.
If you're interested in exploring the other parts of our MERN stack authentication series, feel free to take a look right here!
Throughout this series, we have developed an authentication system using the MERN stack, which allows users to log in, log out, register, and access role-based authorization. So far, we have accomplished a great deal of work on both the frontend and backend. π«‘
Now, it's time to implement this last feature, and our authentication system will be ready to go! π
Understanding the Password Reset Process
Before delving into the code, let's first discuss the underlying functionality to better understand how the password reset process works.
Let's break down this process step by step:
During the login process, the user clicks on the "Forgot Password" option to reset their password.
The user then enters the email address they used in our app. Since the email address is unique for each account, we can verify whether the user has entered the email that is actually stored in the database.
If the entered email is correct, we will generate a unique JWT reset token, similar to how we did with refresh and access tokens. We will then send a URL link that includes this token to the user's email, allowing them to reset their password.
The user is directed to a special page where they can change their password.
The server validates the reset token sent along with the request. If the reset token is correct, we will update the user's password in the database, and they can now log in with ease.
Don't worry if it sounds a bit confusing at first - it's pretty similar to how we renewed the access token earlier. I'm sure it'll all make sense once we dive into the coding part together.
Alright, let's get started with our backend logic π
Setting Up Email Sending for Password Reset
We'll start with the controllers/auth.ts
file. We need to create two new controllers:
One controller is dedicated to the process of receiving the user's request containing their email address, generating a reset password link with the reset token, and sending all of this information to the provided email.
The other controller is dedicated to the process of getting a new password value from the user, verifying the reset token sent along with the request, and updating the database.
Before starting to work on the first controller, we need to explore a new topic in this series: sending emails with Node.js. To accomplish this, we'll use a package called "nodemailer". You can read more about it here.
I recently shared a thread on Twitter/X on sending emails using Nodemailer. Feel free to take a look and boost your skills in this area:
To get started, let's first install the package. Don't worry, this is the last one we need to install, I promise! π Just run this command:
npm install nodemailer
After installing the package, the only thing required to make it work is creating the transporter object. This object is actually responsible for sending the emails. Nodemailer uses SMTP (Simple Mail Transfer Protocol) as the primary transport for delivering messages. This protocol is used by all major email clients, making it truly universal.
To make it easier to understand, I'll show you small parts of the code that use Nodemailer for password resetting. Then, I'll give you the whole code for the controller.
To create the transport object, we must call the createTransport
method:
const transporter = nodemailer.createTransport({
host: "smtp.ethereal.email",
port: 587,
auth: {
user: process.env.ETHEREAL_USER,
pass: process.env.ETHEREAL_PASSWORD
}
});
Inside the method, we need to provide the config object that contains the host
, which is the SMTP service we use, and auth
, the authentication data.
For this tutorial, we'll use Ethereal, a free SMTP service that never actually delivers emails. It's perfect for training since we can set it up with just one click. Additionally, it provides the ability to preview the message, showing us how it would look if it were actually delivered, which is more than enough for our purposes.
To begin using Ethereal:
Visit their website
Choose "Create Ethereal Account." You will receive a username and password. You can either store these in environment variables, as I have done or directly incorporate them into your code.
Next, we need to call the sendMail
method to, well, send the email π:
transporter.sendMail(mailData, (error, info) => {
if (error) {
res.status(500).json({ message: "Error sending email" });
} else {
res.status(200).json({ message: nodemailer.getTestMessageUrl(info) });
}
});
Inside this method, we're passing mailData
, which represents the email configuration, including its content, as well as a callback function to run once the message is delivered or failed.
If the message is delivered successfully, we're sending back the URL that allows us to preview the sent email (keep in mind that Ethereal is a fake service that doesn't actually deliver emails).
OK, and here is how the mailData
looks like:
const mailData = {
from: "alene.kozey@ethereal.email",
to: email,
subject: "Password Reset",
html: `
<p>Hello,</p>
<p>You have requested to reset your password. Please click the link below to reset it within next 15 minutes: </p>
<a href="http://localhost:5173/reset-password?token=${resetToken}">Reset Password</a>
<p>If you didn't request this, please ignore this email.</p>
`
};
Everything is quite straightforward here: we mention the sender and receiver of the message, as well as its subject and content. Notice that within the function leading to password reset, we pass the reset token as the query string.
Now that you've got a quick intro to sending emails with Nodemailer, let's jump back into developing the code for the entire controller:
// controllers/auth.ts
export const sendResetPasswordEmail = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email } = req.body;
const user = await User.findOne({ email });
if (!user) {
return res.status(400).json({ message: "User with given email is not found" });
}
const resetToken = generateJWT(user.id, process.env.RESET_TOKEN_SECRET || "", "15m");
const transporter = nodemailer.createTransport({
host: "smtp.ethereal.email",
port: 587,
auth: {
user: process.env.ETHEREAL_USER,
pass: process.env.ETHEREAL_PASSWORD
}
});
const mailData = {
from: "alene.kozey@ethereal.email",
to: email,
subject: "Password Reset",
html: `
<p>Hello,</p>
<p>You have requested to reset your password. Please click the link below to reset it within next 15 minutes: </p>
<a href="http://localhost:5173/reset-password?token=${resetToken}">Reset Password</a>
<p>If you didn't request this, please ignore this email.</p>
`
};
transporter.sendMail(mailData, (error, info) => {
if (error) {
res.status(500).json({ message: "Error sending email" });
} else {
res.status(200).json({ message: nodemailer.getTestMessageUrl(info) });
}
});
} catch (error) {
next(error);
}
};
Let's break it down step by step:
We get the email from the request body and verify whether the email is valid.
Next, we generate the reset token using the custom
generateJWT
function. This function creates the JWT using the user's ID, a secret key to sign the token (DON'T FORGET TO UPDATE YOUR.env
file with the value for RESET_TOKEN_SECRET), and an expiration time.I'll remind you what the
generateJWT
method looks like, but if you want a more detailed explanation, I refer you to my previous blog post in this authentication series.
// controllers/auth.ts
const generateJWT = (userId: string, TOKEN_SECRET: string, expiryTime: string) => {
return jwt.sign(
{ id: userId },
TOKEN_SECRET,
{ expiresIn: expiryTime }
);
};
After that, weβre making exactly the same that I described above: creating the transporter object with the account set up in Ethereal and sending the email with the provided data as well as a callback function to react on successful/failed email delivery.
After that, we do what I mentioned before: make the transporter object using the Ethereal account, send the email with the given data, and use a callback function to handle successful or failed email delivery.
One more thing to do is to actually add the new route within routes/auth.ts
:
// routes/auth.ts
import { sendResetPasswordEmail } from "../controllers/auth";
...
router.post("/forgot-password", sendResetPasswordEmail);
Integrating Forgot Password Functionality
Now, let's go to the frontend and finish the first part of resetting the password. If you followed the earlier steps in this series, you might remember we added a "Forgot Password" option in the Login
component. To refresh your memory, here's what our Login
renders:
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
Login
</Typography>
<Box
component="form"
onSubmit={handleSubmit}
noValidate
sx={{ mt: 1 }}
>
<TextField
margin="normal"
required
fullWidth
label="Email"
name="email"
autoComplete="email"
autoFocus
value={formData.email}
onChange={(e) => setFormData(prevData => ({...prevData, [e.target.name]: e.target.value}))}
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
autoComplete="current-password"
value={formData.password}
onChange={(e) => setFormData(prevData => ({...prevData, [e.target.name]: e.target.value}))}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Login
</Button>
<Grid container>
<Grid item xs>
<Box component={Link} to="/forgot-password">
Forgot password?
</Box>
</Grid>
<Grid item>
<Box component={Link} to="/register">
{"Don't have an account? Sign Up"}
</Box>
</Grid>
</Grid>
</Box>
</Box>
</Container>
);
The issue was that selecting this option had no effect. Now, let's create the ForgotPassword
page to which users will be directed if they forget their password. We'll create the ForgotPassword.tsx
file inside the pages
folder using the following code:
// pages/ForgotPassword.tsx
import { useState } from "react";
const ForgotPassword = () => {
const [emailToReset, setEmailToReset] = useState<string>("");
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
};
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginY: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<LockOutlinedIcon />
</Avatar>
<Box
component="form"
onSubmit={handleSubmit}
noValidate
sx={{ mt: 1 }}
>
<InputLabel>
Enter the email for the account you forgot the password for:
</InputLabel>
<TextField
margin="normal"
required
fullWidth
label="Email"
name="email"
autoComplete="email"
autoFocus
value={emailToReset}
onChange={(e) => setEmailToReset(e.target.value)}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Send Reset Link
</Button>
</Box>
</Box>
{emailLink && (
<Typography variant="h5">
<Link href={emailLink}>View the email</Link>
</Typography>
)}
</Container>
);
};
export default ForgotPassword;
You can see that this is a simple form containing only one field for the controlled input. Now, we need to process the logic of what happens when the user submits the form. More specifically, we need to send a POST request to the backend with the entered email and receive the email preview link in return.
Thus, we'll create a new mutation inside our API slice stored in the features/auth/authApiSlice.ts
file. To do this, we need to add a new endpoint within the injectEndpoints
method:
// features/auth/authApiSlice.ts
import { apiSlice } from "../../app/api/apiSlice";
import { logout } from "./authSlice";
export const authApiSlice = apiSlice.injectEndpoints({
endpoints: builder => ({
login: builder.mutation({
query: credentials => ({
url: "/auth/login",
method: "POST",
body: {...credentials}
})
}),
register: builder.mutation({
query: credentials => ({
url: "/auth/register",
method: "POST",
body: {...credentials}
})
}),
logout: builder.query<void, void>({
query: () => "/auth/logout",
async onQueryStarted(_, { dispatch, queryFulfilled }) {
await queryFulfilled;
dispatch(logout());
}
}),
// NEW ENDPOINT
sendResetPasswordEmail: builder.mutation({
query: email => ({
url: "/auth/forgot-password",
method: "POST",
body: { email }
})
})
})
});
Everything is quite similar to other mutations we've created previously. We define the URL, request method, and pass the email to the request body.
Don't forget to add the newly generated mutation hook to the export statement, along with the other created hooks:
// features/auth/authApiSlice.ts
export const {
useLoginMutation,
useRegisterMutation,
useLogoutQuery,
useSendResetPasswordEmailMutation
} = authApiSlice;
Now, let's use it within our ForgotPassword
component:
// pages/ForgotPassword.tsx
import { useSendResetPasswordEmailMutation } from "../features/auth/authApiSlice";
const ForgotPassword = () => {
const [emailToReset, setEmailToReset] = useState<string>("");
const [sendResetPasswordEmail] = useSendResetPasswordEmailMutation();
const [emailLink, setEmailLink] = useState<string>("");
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const result = await sendResetPasswordEmail(emailToReset).unwrap();
if (!result.isError) {
setEmailLink(result.message);
}
};
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginY: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<LockOutlinedIcon />
</Avatar>
<Box
component="form"
onSubmit={handleSubmit}
noValidate
sx={{ mt: 1 }}
>
<InputLabel>
Enter the email for the account you forgot the password for:
</InputLabel>
<TextField
margin="normal"
required
fullWidth
label="Email"
name="email"
autoComplete="email"
autoFocus
value={emailToReset}
onChange={(e) => setEmailToReset(e.target.value)}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Send Reset Link
</Button>
</Box>
</Box>
{emailLink && (
<Typography variant="h5">
<Link href={emailLink}>View the email</Link>
</Typography>
)}
</Container>
);
};
export default ForgotPassword;
So, we've made a few changes:
Introduced the
emailLink
state, which represents the server response. More specifically, this URL link lets us preview the email that would be sent.Since the email isn't really being sent, we'll show the link to view the imaginary email instead. We'll display this only when the
emailLink
isn't empty, so we don't send users to a blank page. In case you missed it, this change is reflected in the following lines of code:
{emailLink && (
<Typography variant="h5">
<Link href={emailLink}>View the email</Link>
</Typography>
)}
- Then, we are declaring the mutation hook that we previously defined within our API slice:
const [sendResetPasswordEmail] = useSendResetPasswordEmailMutation();
- We are managing the form submission within the
handleSubmit
method. Here, we send a POST request containing the entered email. If the request is successful, we update the state with the new link for the email preview.
Now, donβt forget to add the appropriate route for this component inside our App
component, where the rest of the routing is defined:
// App.tsx
import Login from "./pages/Login";
import Register from "./pages/Register";
import Logout from "./pages/Logout";
import Home from "./pages/Home";
import ProtectedRoute from "./components/routes/ProtectedRoute";
import Admin from "./pages/Admin";
import ForgotPassword from "./pages/ForgotPassword";
const App = () => {
return (
<>
<ToastContainer
autoClose={3000}
/>
<CssBaseline />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/logout" element={<Logout />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route element={<ProtectedRoute />}>
<Route path="/" element={<Home />} />
<Route path="/admin" element={<Admin />} />
</Route>
</Routes>
</>
);
};
export default App;
Here's what our letter looks like:
Completing the Backend for Password Reset
Great, now let's focus on the second part of the project, which involves the user entering a new password. To do so, we need to return to the backend, specifically the controllers/auth.ts
file, where we will declare the final controller in this series π«‘
// controllers/auth.ts
export const resetPassword = async (req: Request, res: Response, next: NextFunction) => {
try {
const { password } = req.body;
if (!password) {
return res.status(400).json({ message: "New password is required" });
}
const resetToken = req.query.token;
jwt.verify(
resetToken as string,
process.env.RESET_TOKEN_SECRET || "",
async (err: Error | null, decoded: any) => {
if (err) {
return res.status(403).json({ message: err.message });
}
const user = await User.findById(decoded.id);
if (!user) {
return res.status(400).json({ message: "User is not found" });
}
const cookies = req.cookies;
if (cookies?.jwt) {
res.clearCookie("204", { httpOnly: true, sameSite: "strict", secure: true });
}
user.refreshToken = "";
const hashedPassword = await bcrypt.hash(password, 10);
user.password = hashedPassword;
await user.save();
res.status(200).json({ message: "Password changed successfully" });
}
);
} catch (error) {
next(error);
}
};
Alright, so what are we doing here?
First, we obtain the password from the request body, as well as the reset token passed as the query string.
Following that, we verify the received reset token using the secret key we previously used to sign it. Additionally, we provide a callback function to handle the successfully or unsuccessfully decoded token.
In the callback function, we check if the decoded information has the user's ID. Then, we find the cookies with the old refresh token and remove it. After that, we change the password, save it, and send a message saying the password was changed successfully.
Now, let's include this controller in our routes as well:
import { resetPassword } from "../controllers/auth";
// other routes...
router.post("/reset-password", resetPassword);
Yay, we've completed the backend part! π Now, let's dive into the frontend and make it work! You might have noticed, from the email preview, that it includes the reset link. As I mentioned earlier, we'll create a special page just for letting users enter their new password. But don't forget, this page has a unique URL for each user since it's based on their reset token.
Frontend Integration for Password Reset
To kick things off, let's create a new page called ResetPassword.tsx
inside our pages
directory:
// pages/ResetPassword.tsx
import Container from "@mui/material/Container";
import Box from "@mui/material/Box";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import Avatar from "@mui/material/Avatar";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import InputLabel from "@mui/material/InputLabel";
import { useState } from "react";
const ResetPassword = () => {
const [newPassword, setNewPassword] = useState<string>("");
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
};
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<LockOutlinedIcon />
</Avatar>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
<InputLabel>
Enter the new password:
</InputLabel>
<TextField
margin="normal"
required
fullWidth
label="Password"
name="password"
autoComplete="password"
type="password"
autoFocus
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Reset Password
</Button>
</Box>
</Box>
</Container>);
};
export default ResetPassword;
Our component is pretty much like the one in ForgotPassword.tsx
. We've got a form with just one controlled input for the new password. Right now, we don't have any logic for submitting the new password, but we can easily sort that out!
We need to go back to our endpoints within features/auth/authApiSlice.ts
to define a new mutation that will send a POST request with the new password. The code for this is provided below:
// features/auth/authApiSlice.ts
resetPassword: builder.mutation({
query: ({ token, password }) => ({
url: `/auth/reset-password?token=${token}`,
method: "POST",
body: { password }
})
})
The mutation for sending emails is quite similar to this one. The only distinction is that we include the reset token within our URL as the query string.
Additionally, ensure you export the newly-created mutation hook:
export const {
useLoginMutation,
useRegisterMutation,
useLogoutQuery,
useSendResetPasswordEmailMutation,
useResetPasswordMutation
} = authApiSlice;
Great, now the final step is to update our ResetPassword
component so that it sends the updated password to the server.
// pages/ResetPassword.tsx
import { useResetPasswordMutation } from "../features/auth/authApiSlice";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "react-toastify";
const ResetPassword = () => {
const navigate = useNavigate();
const [newPassword, setNewPassword] = useState<string>("");
const [resetPassword] = useResetPasswordMutation();
const [searchParams] = useSearchParams();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const result = await resetPassword({ token: searchParams.get("token"), password: newPassword }).unwrap();
if (!result.isError) {
toast.success(result.message);
navigate("/");
}
};
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<LockOutlinedIcon />
</Avatar>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
<InputLabel>
Enter the new password:
</InputLabel>
<TextField
margin="normal"
required
fullWidth
label="Password"
name="password"
autoComplete="password"
type="password"
autoFocus
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Reset Password
</Button>
</Box>
</Box>
</Container>);
};
export default ResetPassword;
That's quite a bit of code. Let's break it down:
Nothing has changed in terms of rendering, meaning there is nothing new inside the
return
statement.We retrieve the unique reset token from the query parameters using the
useSearchParams
hook defined within React Router. You can learn more about it in the documentation. As seen in our mutation, we use this token within the request URL.We use the mutation hook exported earlier to send the request:
const [resetPassword] = useResetPasswordMutation();
We are sending the request, and if the request is successful, we display the appropriate toast notification message.
Lastly, we need to add one more route to our ResetPassword
page. The code for the App
will appear as follows:
// App.tsx
import Login from "./pages/Login";
import Register from "./pages/Register";
import Logout from "./pages/Logout";
import Home from "./pages/Home";
import ProtectedRoute from "./components/routes/ProtectedRoute";
import Admin from "./pages/Admin";
import ForgotPassword from "./pages/ForgotPassword";
import ResetPassword from "./pages/ResetPassword";
const App = () => {
return (
<>
<ToastContainer
autoClose={3000}
/>
<CssBaseline />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/logout" element={<Logout />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route element={<ProtectedRoute />}>
<Route path="/" element={<Home />} />
<Route path="/admin" element={<Admin />} />
</Route>
</Routes>
</>
);
};
Huh, weβre done. Letβs test how it works:
Wrap Up
Woo-hoo! We've done it! A fantastic authentication system with dual tokens and password reset capabilities! Get ready to supercharge your next app with this incredible, enhanced authentication process! π
Wow, that was quite a journey, wasn't it? It took a bit longer than expected, but we explored so many cool ideas on both frontend and backend. I really hope this series taught you some new things about authentication, and also gave you a deeper understanding of the MERN stack, Redux, Material UI, and more! π
Ultimately, we now have an authentication system that is far more secure than, for instance, storing a single access token in local storage, which can be compromised through an XSS attack. I believe we've done an excellent job. Congratulations π If you missed any part of the series, make sure to catch on here.
As usual, if you encounter any difficulties along the way, you can refer to the complete source code available on my GitHub repository. If you have any questions or need assistance, don't hesitate to reach out to me. Feel free to contact me, even if it's not related to this project.
If you enjoyed this article, remember to follow me on Twitter/X, where I share daily updates. And feel free to connect with me on LinkedIn.