JWTs for session management
June 01st, 2021

Implementing a forgot password flow (with pseudo code)

Table of contents:

Security Issues to consider:

How to implement a secure password reset flow

  1. User’s enters email in the UI
  2. Creating password reset token and storing it in DB
  3. Sending token to the user and verifying it when used

Users often forget their passwords and the applications we make have to account for that. This opens a potential attack vector because anyone can request a new password. Resetting a password requires sending a token to a user’s email address and this is what gives attackers an opening. Making sure you have a secure process for handling the password reset tokens will ensure your users’ accounts remain safe from attackers.

Security Issues to Consider

Brute force attacks

This is a common threat for all web applications. Attackers may attempt to detect patterns in the password reset tokens - like if it’s derived from a user’s userId, time they signed up, their email or any other information. Attackers may also try all possible combinations of letters and numbers (brute force), and may even succeed if  the generated tokens are not long or random enough (i.e. have low entropy).  

To prevent this, we must ensure that tokens are generated using a secure random source, and that they are long enough (we recommend >= 64 characters). Later on in this blog, we will see one such method.

Database theft of password reset tokens

There are several ways an attacker can gain access to an application's database: SQL injection attacks, targeting unpatched database vulnerabilities, and exploiting unused database services. They could even gain access if someone hasn't updated the default login credentials.

While there are plenty of problems with an attacker getting database access, one of them is their ability to get users' password reset tokens. To mitigate this risk, we store only the hashed version of tokens in the database. Passwords are hashed and stored and its important that password reset tokens are too (for the same reasons).

Another related attack vector is the use of JWTs as the password reset token. Whilst this makes development easy, a major risk is that if the secret key used to sign them is compromised, the attacker can use that to generate their own valid JWT. This would allot them to reset any user’s password. Therefore, we must never use JWTs as password reset tokens (rework language).

Reusing existing tokens

For simplicity of development, it may be tempting to store a “static” password reset token per user. This token might be randomly generated on user sign up, or based on their password’s hash or some other information.

This in turn implies that these tokens cannot be stored in a hashed form in the database - since we will need to send this token over email (because if we hash it, we can’t unhash it at the time). Therefore, if the database is compromised, these tokens can be used to reset a user’s password.

Another risk of reusing tokens is that if a token somehow gets leaked, even if it has been redeemed by the actual user, it can still be used by an attacker to change that user’s password. 

Attackers can gain access to a user's email account through email hijacking. This usually happens with phishing emails, social engineering, or by getting them to enter their credentials in a bogus login form. Once they have access to a user's email, they are able to trigger a password reset link and gain access to the user’s account.

This risk can be mitigated by enabling two-factor authentication via SMS or an authenticator app, or by asking secret questions to the user before allowing them to reset their password.

How to implement a secure password reset flow

To ensure that your password reset process is as secure as possible, here is a potential flow that takes into account the security issues we discussed above.

User enters their email in the UI requesting a password reset

This is how users will begin the process for updating their password. There's usually a simple form available that lets them enter the email address associated with their account.

When they submit the email address, this will trigger the back-end to check if that email exists in the database. Even if the email doesn't exist, we'll show a message that says the email has been sent successfully. That way we don't give attackers any indication that they should try a different email address.

If the email does exist in the database, then we create a new password reset token, store its hashed version in the database, and generate a password reset link that's sent to the user's email address.

Creating a password reset token

With Supertokens, the password reset token is generated using a random 64 character string. This prevents brute force attacks mentioned above since new tokens are unguessable, and have high entropy. 

For Java (uptill line 120)

For Java ‍(uptill line 120)

For NodeJS

For Python and use secrets.token_urlsafe

For Python and use secrets.token_urlsafe

Storing the token in the database

After the token has been created, it's hashed using SHA256 and stored in the database along with the user’s ID and it's assigned an expiration time. That way the token is only valid for a set amount of time, blocking attacks that could happen if the token never expired. Here’s an example of the db schema we can use to store password reset tokens.

CREATE TABLE password_reset_tokens (    
    user_id VARCHAR(36) NOT NULL,    
    token VARCHAR(128) NOT NULL UNIQUE,    
    token_expiry BIGINT UNSIGNED NOT     NULL,    
    PRIMARY KEY (user_id, token),
); 

If you notice, we allow multiple tokens to be stored per user. This is necessary since we only store the hashed version of the tokens in the db. This means that if a user requests multiple tokens at the same time, we cannot send them the same previously generated token (which is not yet redeemed), since it’s stored in hashed form.

At the end, we want to generate a password reset link which points to a link on your website that displays the “enter new password” form, and also contains the token. An example of this is:

https://example.com/reset-password?token=<Token here>

The user clicks on the link in the email

Once the user has received the email, they will click on the link and it will redirect them to a page on the website to enter their new password. The password validators should follow the same rules as that in a sign up form.

The new password is submitted along with the token to the back-end

Once they enter the new password, the reset token and the new password are sent to the back-end. The reset password token is obtained from the password reset link’s query params.

In summary, if the token’s hash matches what was stored in the database, the user's password will be updated with the new password. Otherwise, the user will have to request a new reset token and go through the process again.

What happens on the back-end

Here is a pseudo code of your backend API logic for redeeming a token:

function redeemToken(passwordResetToken, newPassword) {
       /*
       First we hash the token, and query the db based on the hashed value. If nothing is found, then we        throw an error.
    */

       hashedToken = hash_sha256(passwordResetToken);
       rowFromDb = db.getRowTheContains(hashedToken)
       if (rowFromDb == null) {
           throw Error(“invalid password reset token”)
       }
       
userId = rowFromDb.user_id
       /*
       Now we know that the token exists, so it is valid. We start a transaction to prevent race conditions.
     */
       

       db_startTransaction() {
                /*   allTokensForUser is an array of db rows. We have to use a query that locks all
                      the rows in the table that belong to this userId. We can use something like “SELECT *                       FROM password_reset_tokens where user_id = userId FOR UPDATE”. The “FOR                       UPDATE" part locks all the relevant rows.      */  

               allTokensForUser = db_getAllTokensBasedOnUser(userId)
               /*
                   We search for the row that matches the input token’s hash, so that we know that another
                    transaction has not redeemed it already.                
            */ 

             matchedRow = null;
             allTokensForUser.forEach(row => {
                   if (row.token == hashedToken) {
                          matchedRow = row;
                   }
              });

             if (matchedRow == null) {
                  /* The token was redeemed by another transaction. So we exit */
                  throw Error(invalid password reset token)
             }
             /*
             Now we will delete all the tokens belonging to this user to prevent duplicate use              
            */

            db_deleteAllRowsForUser(userId)
             /*
            Now we check if the current token has expired or not.
            */

            if (matchedRow.token_expiry < time_now()) {
                db_rollback();
                throw Error(Token has expired. Please try again);
             }
             /* Now all checks have been completed. We can change the user’s password */
             hashedAndSaltedPassword = hashAndSaltPassword(newPassword);
             db_saveNewPassword(userId, hashedAndSaltedPassword);
             db_commitTransaction();
        }
 }

function redeemToken(passwordResetToken, newPassword) {
       /*
       First we hash the token, and query the db based on the hashed value. If nothing is found, then we throw an error.
    */

       hashedToken = hash_sha256(passwordResetToken);
       rowFromDb = db.getRowTheContains(hashedToken)
       if (rowFromDb == null) {
           throw Error(“invalid password reset token”)
       }
       
userId = rowFromDb.user_id
       /*
       Now we know that the token exists, so it is valid. We start a transaction to prevent race conditions.
     */
       

       db_startTransaction() {
                /*   allTokensForUser is an array of db rows. We have to use a query
that locks all the rows in the table that belong to this userId. We can use
something like “SELECT * FROM password_reset_tokens where user_id =
userId FOR UPDATE”. The “FOR UPDATE" part locks all the relevant rows.   */  

               allTokensForUser = db_getAllTokensBasedOnUser(userId)
               /*
        We search for the row that matches the input token’s hash, so that we know that another transaction has not redeemed it already.                
            */ 

             matchedRow = null;
             allTokensForUser.forEach(row => {
                   if (row.token == hashedToken) {
                          matchedRow = row;
                   }
              });

             if (matchedRow == null) {
                  /* The token was redeemed by another transaction. So we exit */
                  throw Error(invalid password reset token)
             }
             /*
             Now we will delete all the tokens belonging to this user to prevent duplicate use              
            */

            db_deleteAllRowsForUser(userId)
             /*
            Now we check if the current token has expired or not.
            */

            if (matchedRow.token_expiry < time_now()) {
                db_rollback();
                throw Error(Token has expired. Please try again);
             }
             /* Now all checks have been completed. We can change the user’s password */
             hashedAndSaltedPassword = hashAndSaltPassword(newPassword);
             db_saveNewPassword(userId, hashedAndSaltedPassword);
             db_commitTransaction();
        }
 }

function redeemToken(passwordResetToken,    newPassword) {
       /*
       First we hash the token, and query the db based on the hashed value. If nothing is found, then we throw an error.
    */

       hashedToken =        hash_sha256(passwordResetToken);
       rowFromDb =        db.getRowTheContains(hashedToken)
       if (rowFromDb == null) {
           throw Error(“invalid password reset token”)
       }
       
userId = rowFromDb.user_id
       /*
       Now we know that the token exists, so it is.        valid. We start a transaction to prevent race        conditions.
     */
       

       db_startTransaction() {
                /*   allTokensForUser is an array of db                       rows. We have to use a query
                      that locks all the rows in the table                       that belong to this userId. We can                       use something like “SELECT *                       FROM password_reset_tokens                       where user_id =
                      userId FOR UPDATE”. The “FOR                       UPDATE" part locks all the relevant                       rows.   */  

             allTokensForUser =              db_getAllTokensBasedOnUser(userId)
               /*
               We search for the row that matches the                input token’s hash, so that we know that                another transaction has not redeemed it                already.                
            */ 

             matchedRow = null;
             allTokensForUser.forEach(row => {
                   if (row.token == hashedToken) {
                          matchedRow = row;
                   }
              });

             if (matchedRow == null) {
                  /* The token was redeemed by                   another transaction. So we exit */
                  throw Error(invalid password                                       reset token)
             }
             /*
             Now we will delete all the tokens              belonging to this user to prevent duplicate              use              
            */

            db_deleteAllRowsForUser(userId)
             /*
            Now we check if the current token has             expired or not.
            */

            if (matchedRow.token_expiry <                 time_now()) {
                   db_rollback();
                   throw Error(Token has expired.                                         Please try again);
             }
             /* Now all checks have been completed.              We can change the user’s password */
             hashedAndSaltedPassword =              hashAndSaltPassword(newPassword);
             db_saveNewPassword(userId,                   hashedAndSaltedPassword);
             db_commitTransaction();
        }
 }

Conclusion

Password reset flows are easy to get wrong. One needs to be knowledgeable in cryptography, database transactions / distributed locking, and to be able to think about all the edge cases in the flow. The cost of getting these wrong can be compromised user accounts.

At SuperTokens.io, we have tried to provide an easy to use, open source, password reset solution (and lots of other auth modules) that you can use to secure your app and save time.

Written by the Folks at SuperTokens — hope you enjoyed! We are always available on our Discord server. Join us if you have any questions or need any help.