Resetting a User’s Password Using Node.js and JWT

Recently I was tasked with building a feature that allowed a user to reset her password via email — securely. I used Node and Express on the backend. Turns out this was both easier and harder than I expected.

Resetting a User’s Password Using Node.js and JWT

tags: nodejsjwtcybersecurity

JS Everywhere

We were using JWT for encoding sensitive data passed between the client and server, so I decided to stick with that because typing npm i sounded hard.

Check out the project’s source code on GitHub, or watch the 30 second demo:

We’ll be coding this feature entirely in JavaScript. Let’s dive in!


  • Express & JWT: I’m assuming you know enough Express to create a simple service that listens on a given route. Working knowledge of JWTs will also help; if you’re fuzzy, check out the JWT website for a refresher on JWT headers, payloads and signatures.
  • Nodemailer: Super simple npm module for sending email. We’ll create a quick and dirty email template and configure it to send reset emails from our own email address — make sure you use something like dotenv if you plan on pushing to GitHub, otherwise your email password will be made public!


This is the part of the blog post I would usually skip personally, but I recommend you actually read this part, otherwise you might end up being confused when we get to implementation.

Let’s make sure we’re on the same page about a few things, security-wise:

  1. We’re not going to send the user her password via email.
  2. Because we care about security, we’re using something like bcryptjs so that we don’t store the user’s password at all. At least not in plain text. Instead we hash it, and sending the user a hashed password is worse than doing nothing at all because she will probably assume her password has been compromised.
  3. We’re also not going to send the user a temporary password because we can’t assume the user’s inbox is secure. If the user gets distracted and forgets to follow the link we email her, there is a live password just waiting in her inbox. Not to mention a malicious actor or jilted ex could reset the user’s password over and over, keeping her from logging into the wonderful app we made.

Instead, we’re going to generate a single-use link that the user can follow to enter a new password. This link will also expire after a set amount of time, in this case 60 minutes.

Creating a Single-Use URL

We need a way to validate our user. To do this, we’re going to embed a JWT token that identifies her in the URL we generate. When she clicks this link, our user will be presented a form that allows her to enter a new password.

As a payload, the JWT token needs to carry something that we can use to effectively fingerprint the user. Once she visits the route we send her, our server will pull the token from the route params and use it to identify her.

Before we talk about the JWT payload, let’s make sure we all understand the control flow:

Order of operations:

  1. User enters her email into a password reset form which sends a POST request to the endpoint /reset_pw/user/:email
  2. We sign a JWT on the backend using a dynamic payload and secret key
  3. An email service mounted at that route in step 1 emails her a URL containing the token
  4. The user clicks the link and is taken to a client-side form. Then, our client-side router pulls the token off params as it resolves her request (our team used React Router to do this)
  5. User submits the form which makes a POST request to /new_pw/:userId/:token with the new password on the request body (parameters being easier to intercept)
  6. Our then server decodes the token using a secret key unique to the user, hashes the new password, and replaces the old password hash with the new one

JWT Secret & Payload

So what should our JWT’s payload be?

This confused me until I realized I was asking the wrong question. A better question is: What should our secret key be?

The payload itself doesn’t matter as much as the fact that it matches.

Let’s say you and I are both spies, meeting for the first time. We don’t know if we can trust each other yet. Of course, no good spy actually trusts another spy, not truly; but we agree that some tests are better than others, and begin talking about ways to verify that the other is in face who she claims to be.

Before long we reach an impasse. Given our line of work, we cannot use information as a way to authenticate each other, because we might be giving something away by the very act of trying to verify it. We could try blurting out the name of the target together on three, but that’s the oldest trick in the book and neither one of us is falling for that one again.

Instead, you point out that it doesn’t actually matter what we know, but that we can demonstrate the ability to know it. We don’t need to know what’s behind door number 1, so long as we both have a key that can open it.

To make a one-time URL, we use the user’s old hashed password as the JWT secret key. When the user updates her password, we will replace the old hash with the new one, and no one can access the secret key anymore.

In order to make sure this link can’t be used and reused over and over again, the link should “expire” — meaning that our token (and by extension, the URL) should only work once.

After researching how to do this, I came across a very clever solution:

To make a one-time URL, we use the user’s old hashed password as the JWT secret key. When the user updates her password, we will replace the old hash with the new one, and no one can access the secret key anymore.

This helps to ensure that if the user’s password was the target of a previous attack (on an unrelated website), then the user’s created date will make the secret key unique from the potentially leaked password.

Get it?

It took me a bit to wrap my head around.

To make this even more secure, we can concatenate the old password hash with the user’s createdAt value, so that if someone intercepts the user token from the network, he would still need a user’s timestamp to crack the secret key.

With the combination of the user’s password hash and createdAt date the JWT becomes a one-time-use token, because once the user has changed her password, successive calls to that route will generate a new password hash, invalidating the secret key which references the defunct password.

So what should the payload be?

As long as it’s something unique to the user that our server can compare, that’s up to you. In our case, we used the userId, and the server just made sure that the decoded payload matches our user’s ID in the database.


Now that all the theory is behind us, let’s look at one possible implementation:


File: email.controller.js:

See it on GitHub


1import jwt from "jsonwebtoken"
2import bcrypt from "bcryptjs"
3import { User } from "../user/user.model"
4import {
5 transporter,
6 getPasswordResetURL,
7 resetPasswordTemplate
8} from "../../modules/email"

Here we pull in our User model and email module, which are implementation details. Again, see the GitHub repository if you want to dig into all the specifics.

Make token from hash helper function:

1// `secret` is passwordHash concatenated with user's
2// createdAt value, so if someone malicious gets the
3// token they still need a timestamp to hack it:
4export const usePasswordHashToMakeToken = ({
5 password: passwordHash,
6 _id: userId,
7 createdAt
8}) => {
9 // highlight-start
10 const secret = passwordHash + "-" + createdAt
11 const token = jwt.sign({ userId }, secret, {
12 expiresIn: 3600 // 1 hour
13 })
14 // highlight-end
15 return token

Here we pass the old password hash, user ID and created at values into a function that signs a token with userId as the payload and ${passwordHash}-${createdAt} as a secret.

Sending the email:

1//// Sends an email IRL! ////
2export const sendPasswordResetEmail = async (req, res) => {
3 const { email } = req.params
4 let user
5 try {
6 user = await User.findOne({ email }).exec()
7 } catch (err) {
8 res.status(404).json("No user with that email")
9 }
10 const token = usePasswordHashToMakeToken(user)
11 const url = getPasswordResetURL(user, token)
12 const emailTemplate = resetPasswordTemplate(user, url)
14 const sendEmail = () => {
15 transporter.sendMail(emailTemplate, (err, info) => {
16 if (err) {
17 res.status(500).json("Error sending email")
18 }
19 console.log(`** Email sent **`, info.response)
20 })
21 }
22 sendEmail()

This function find’s our user by email, makes a token, builds up a URL, then calls our email module and fires off an email IRL.

Updating the user’s password:

1export const receiveNewPassword = (req, res) => {
2 const { userId, token } = req.params
3 const { password } = req.body
5 // highlight-start
6 User.findOne({ _id: userId })
7 .then(user => {
8 const secret = user.password + "-" + user.createdAt
9 const payload = jwt.decode(token, secret)
10 if (payload.userId === {
11 bcrypt.genSalt(10, function(err, salt) {
12 // Call error-handling middleware:
13 if (err) return
14 bcrypt.hash(password, salt, function(err, hash) {
15 // Call error-handling middleware:
16 if (err) return
17 User.findOneAndUpdate({ _id: userId }, { password: hash })
18 .then(() => res.status(202).json("Password changed accepted"))
19 .catch(err => res.status(500).json(err))
20 })
21 })
22 }
23 })
24 // highlight-end
26 .catch(() => {
27 res.status(404).json("Invalid user")
28 })

This nasty function is the meat of the program. Originally we wrote this using the async/await protocol like sendPasswordResetEmail above, but bcrypt didn’t seem to like async/await.

What’s interesting is that, despite the callback hell, this function never created any bugs and never needed to be rewritten.

File: email.restRouter.js

See it on GitHub

Then we mount the service in email.restRouter.js (rest router as opposed to GraphQL router, which this blog is written in):

1import express from "express"
2import * as emailController from "./email.controller"
4export const emailRouter = express.Router()
9 .route("/receive_new_password/:userId/:token")
10 .post(emailController.receiveNewPassword)

What about the client?

Initially we were going to do everything on the server, but then we decided that React Router and Axios were enough to pass params to and from the Express server.

There are really only 2 client-side files of interest.

File: RecoverPassword.js

See it on GitHub

This component doesn’t need to accept any props because the user hasn’t taken any action yet besides navigate to the component. Notice how the component renders based on local state, and how the Axios call to our server endpoint is made:

1import React, { Component } from "react"
2import { Link } from "react-router-dom"
3import styled from "styled-components"
5import { Button, GhostInput } from "./styledComponents"
6import RecoverPasswordStyles from "./RecoverPassword.styles"
8import axios from "axios"
9import SERVER_URI = "localhost:3000"
11class RecoverPassword extends Component {
12 state = {
13 email: "",
14 submitted: false
15 }
17 handleChange = e => {
18 this.setState({ email: })
19 }
21 sendPasswordResetEmail = e => {
22 e.preventDefault()
23 const { email } = this.state
25 this.setState({ email: "", submitted: true })
26 }
28 render() {
29 const { email, submitted } = this.state
31 return (
32 <RecoverPasswordStyles>
33 <h3>Reset your password</h3>
34 {submitted ? (
35 <div className="reset-password-form-sent-wrapper">
36 <p>
37 If that account is in our system, we emailed you a link to reset
38 your password.
39 </p>
40 <Link to="/login" className="ghost-btn">
41 Return to sign in
42 </Link>
43 </div>
44 ) : (
45 <div className="reset-password-form-wrapper">
46 <p>
47 It happens to the best of us. Enter your email and we'll send you
48 reset instructions.
49 </p>
50 <form onSubmit={this.sendPasswordResetEmail}>
51 <GhostInput
52 onChange={this.handleChange}
53 value={email}
54 placeholder="Email address"
55 />
56 <Button className="btn-primary password-reset-btn">
57 Send password reset email
58 </Button>
59 </form>
60 <Link to="/login">I remember my password</Link>
61 </div>
62 )}
63 </RecoverPasswordStyles>
64 )
65 }
68export default RecoverPassword

That’s it, it’s actually pretty simple. We can use React Router to mount it wherever we want. We went with /password/recover because we had other password functionality that we bundled under the /password namespace.

File: UpdatePassword.js

See it on GitHub

Here’s how we might use the UpdatePassword component using React Router:

2 path="/update-password"
3 render={({ match }) => (
4 <UpdatePassword userId={match.params.userId} token={match.params.token} />
5 )}

And here’s what the component itself looks like:

1import React, { Component } from "react"
2import axios from "axios"
3import { Link } from "react-router-dom"
4import PropTypes from "prop-types"
6import { Button, GhostInput } from "./customStyledComponents"
7import { RecoverPasswordStyles as UpdatePasswordStyles } from "./RecoverPassword"
9const SERVER_URI = "localhost:3000"
11class UpdatePassword extends Component {
12 state = {
13 password: "",
14 confirmPassword: "",
15 submitted: false
16 }
18 handleChange = key => e => {
19 this.setState({ [key]: })
20 }
22 updatePassword = e => {
23 e.preventDefault()
24 const { userId, token } = this.props
25 const { password } = this.state
27 axios
28 .post(
29 `${SERVER_URI}/reset_password/receive_new_password/${userId}/${token}`,
30 { password }
31 )
32 .then(res => console.log("RESPONSE FROM SERVER TO CLIENT:", res))
33 .catch(err => console.log("SERVER ERROR TO CLIENT:", err))
34 this.setState({ submitted: !this.state.submitted })
35 }
37 render() {
38 const { submitted } = this.state
40 return (
41 <UpdatePasswordStyles>
42 <h3 style={{ paddingBottom: "1.25rem" }}>Update your password</h3>
43 {submitted ? (
44 <div className="reset-password-form-sent-wrapper">
45 <p>Your password has been saved.</p>
46 <Link to="/login" className="ghost-btn">
47 Sign back in
48 </Link>
49 </div>
50 ) : (
51 <div className="reset-password-form-wrapper">
52 <form
53 onSubmit={this.updatePassword}
54 style={{ paddingBottom: "1.5rem" }}
55 >
56 <GhostInput
57 onChange={this.handleChange("password")}
58 value={this.state.password}
59 placeholder="New password"
60 type="password"
61 />
62 <GhostInput
63 onChange={this.handleChange("confirmPassword")}
64 value={this.state.confirmPassword}
65 placeholder="Confirm password"
66 type="password"
67 />
69 <Button className="btn-primary password-reset-btn">
70 Update password
71 </Button>
72 </form>
73 </div>
74 )}
75 </UpdatePasswordStyles>
76 )
77 }
80UpdatePassword.propTypes = {
81 token: PropTypes.string.isRequired,
82 userId: PropTypes.string.isRequired
85export default UpdatePassword

That’s a wrap! Of course there’s more to the implementation, but for sake of brevity I will simply link to the other relevant files here:

  1. Server-side Email module & Nodemailer config
  2. Server-side Rest Router API config

Thanks for reading! Feel free to drop me a line if you have any questions or feedback at

Check out my other projects on GitHub!