Shane A. Stillwell
A Better Password Reset Token for 2020

A Better Password Reset Token for 2020

Back in 2018, I wrote about a password reset method illustrating how to do a password reset without storing a reset code in the database. This has worked well for a few of my apps, but recently some good friends, we’ll call them Pedro and Rosco, came up with a much better solution.

The first solution most developers reach for is to store a reset code in the database along with the user record. Some also add in a time stamp when the reset code was generated so they can reject expired reset codes. This works, but there are better ways to accomplish this, all without setting and checking a field in your database.

The fastest operation is one that doesn’t happen
— Someone on the Internet

The Secret

When I heard their secret, I was floored it didn’t occur to me earlier, but I’m glad they suggested the change. First, let’s recap our requirements.

  1. Token is created and validated in code, nothing is stored in the database
  2. Token must expire after a set period of time
  3. Token is unique to the user
  4. Token can only be used once. A token used to change a password cannot be used again.

The Code

We’ll need four functions to accomplish these goals. Let’s look at them first, then we’ll talk through them.

const jwt = require('jsonwebtoken')
const crypto = require('crypto')

const SECRET = 'YOUR SECRET YOU STORE IN AN ENVIRONMENT VARIABLE'

// Get the JWT from a user database record
function generateResetCode (user) {
  // User string is a concatenated string of values from the record, we'll use
  // it to create a hash. That way if any of the values changes, so does
  // the hash. Thereby invalidating our reset code
  const userString = getUserString(user)

  // The has is an MD5 hash, it just needs to be unique enough to change when an of the fields for user changes
  const userHash = getUserHash(userString)

  const token = jwt.sign({
    hash: userHash,
  }, SECRET, {
    // This token is set to expire in 1 day
    expiresIn: '1d',

    // The sub (subject) of the token is the user ID we will use to fetch the DB record
    subject: user.id,
  })

  return token
}

// Takes a JWT and returns it's contents only if it's valid
function decodeResetCode (token) {
  return jwt.verify(token, SECRET)
}

// Used by our validation endpoint
function async validateResetCode (code = '', ctx) {
  // Check the code is valid and not expired
  const token = decodeResetCode(code)

  // If the token is invalid or expired, it's not valid
  if (!token) return false

  // Get record from DB
  // This is on you to fetch the user from your database
  // The token.sub is the user.id we stored inside the token
  const user = await getUserFromDatabase(token.sub, ctx)

  // If no user is found, then bail
  if (!user) return false

  // Assemble the hash to ensure they have not already updated their password or
  // used this hash in the past to update the password or any other field in their user record
  const userString = getUserString(user)
  const userHash = getUserHash(userString)

  return (token.hash === userHash)
}

// Generator the MD5 hash from the user record string
function getUserHash (string) {
  return crypto
    .createHash('md5')
    .update(string)
    .digest('hex')
}

// These properties of the user are fields in my database
function getUserString (user) {
  return `${user.id}${user.email}${user.encPassword}${user.updatedAt}`
}

The two functions we depend on are getUserString and getUserHash. The userString is a concatenated string of properties on the user. Items we need to match against later. Remember one of the important criteria was Token can only be used once. By including both the updatedAt and encPassword fields in this user string, we ensure when those fields change, this string (token) will no longer be valid. The getUserHash function converts the string into an MD5 hash. In this case, I deem MD5 a suitable enough cyrpto algorithm since it’s fast and meaningful collisions would be extremely rare.

This hash is stored in a JWT. The token is created using the sign function of the jsonwebtoken package. We also embed the user.id and set the expiration on the token to 1 day.

I should note. The user.id is embedded into the JWT which is included in the reset password link emailed to the user. JWT tokens are not encrypted. Anyone can take the JWT and decode the contents and see the user.id. I use UUID4 ids in my database, so I don’t consider this much of a security issue. We use it later to look up the user to validate the reset token. NEVER STORE SENSITIVE DATA IN A JWT.

JWT Tokens are signed, meaning the contents of the token have a signed hash. If anything inside the token changes, it changes the signature of the token and makes it invalid when we check it with our SECRET.

To check our token for validity, we use the validateResetCode() function. Here, we are decoding the code to get the token contents. If the token is expired or does not match the signature, then it will fail. We check to make sure our token is valid. Then you need to fetch the user from the database so we can assemble our validation hash. Remember this hash will help us ensure the token hasn’t previously been used to reset the password. Once we have the user record in hand, we assemble the hash and compare it to the one in the token.

When we have the user from the DB, we check a few things.

  1. If the user doesn’t exist, then fail
  2. Then we generate the userString and userHash again
  3. If you recall, our user string had id, email, encPassword (encrypted password), and updatedAt. If any of these fields changes, it will change the hash and it will not match what has been passed to us.
  4. Finally we compare the hash passed to us by the client with the hash we generated. If they match, then the password reset is valid.

The Answer. Use JSON Web Tokens for password reset tokens

Here we looked at the infamous password reset flow. We’ve taken a slightly different approach for token validation. Instead of storing the token, we generate one on the fly and will compare it later with the same set of data points. This new approach saves us two updates to the database, first storing the token, and later removing the token. It also saves us from having to hit the database for an expired token.

Recognition

  • Thank you to you MonikaP for the rubber ducky image found on Unsplash
  • Rosco and Pedro (not their real names). Two smart guys I’ve learned many savvy solutions they’ve shared. They will know who they are if they stumble upon this post.