Security Issues to consider:
How to implement a secure password reset flow:
Applications need to account for the frequency with which users forget their passwords.
This opens a potential attack vector because anyone can request a new password on behalf of the legitimate user. Resetting a password requires sending a token to a user’s email address and this provides an opening for attackers.
Making sure you have a secure process for handling the password reset tokens will ensure your users’ accounts remain safe from attackers.
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.
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, like in this research on Paleohacks.
To mitigate this risk, we store only the hashed version of tokens in the database. Passwords are hashed and stored and it’s 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. The JWT secret key (or signing key) must be carefully protected and hence we do not recommend using JWTs as the password reset token.
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.
Stealing Tokens From Email Hijacking
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.
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.
Users Enter Their Email to Initiate the Password Reset Flow
This is how users will begin the process for updating their passwords.
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.
Create a Password Reset Token
With Supertokens, the password reset token is generated using a random 64 character secure random string. This prevents brute force attacks mentioned above since new tokens are unguessable, and have high entropy.
For NodeJS
For Python and use
secrets.
token_urlsafe
Store 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 signup 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();
}
}
Conclusion
Password reset flows are easy to get wrong.
One needs to be knowledgeable in cryptography, database transactions / distributed locking, and should be able to think about all the edge cases in the flow. The cost of getting these wrong can lead to 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.