kwcham's avatar

Reset Password token in Email link does not match in database table

I got this "This password reset token is invalid." error when creating the password reset feature using Laravel 8. The problem is the token sent by email is not matched with the token stored in database table. I used below code to check the equality of these two values and it give "not the same" result.

$pass = DB::table('password_resets')->where('email', '[email protected]')->value('token');
        if(Hash::check($request['token'], $pass))
        {
            dd('same');
         } else {
             dd('not same');
         }

I hope anyone can pinpoint on what i did wrongly or anything that i missed.

My web.php:

Route::get('/forgot_password', 'ResetPasswordController@request')->name(ResetPasswordConstant::RESET_PASSWORD_ROUTE_REQUEST);
Route::post('/forgot_password', 'ResetPasswordController@email')->name(ResetPasswordConstant::RESET_PASSWORD_ROUTE_EMAIL);
Route::get('/reset_password/{token}', 'ResetPasswordController@reset')->name(ResetPasswordConstant::RESET_PASSWORD_ROUTE_RESET);
Route::post('/reset_password', 'ResetPasswordController@update')->name(ResetPasswordConstant::RESET_PASSWORD_ROUTE_UPDATE);

My controller:

public function email(Request $request)
    {
        $request->validate(['email' => 'required|email']);

        $status = Password::sendResetLink($request->only('email'));

        return $status === Password::RESET_LINK_SENT ? back()->with(['status' => __($status)]) : back()->withErrors(['email' => __($status)]);
    }
public function update(Request $request)
    {
        $request->validate([
            'token' => 'required',
            'email' => 'required|email',
            'password' => 'required|min:8|confirmed',
        ]);

        $status = Password::reset(
            $request->only('email', 'password', 'password_confirmation', 'token'),
            function ($user, $password) use ($request) {
                $user->forceFill([
                    'password' => Hash::make($password)
                ])->setRememberToken(Str::random(60));
    
                $user->save();
    
                event(new PasswordReset($user));
            }
        );
    
        return $status == Password::PASSWORD_RESET ? redirect()->route('login')->with('status', __($status)) : back()->withErrors(['email' => [__($status)]]);
    }

password_resets db table:

# email, token, created_at
'[email protected]', 'yLwuR6YvjMvqGykxOycMX.dqu/vF1PIxd05mx2FQIS1YacY6aVhmG', '2021-04-16 14:00:06'

email link sent:

http://localhost:8001/reset_password/5a266d95acd1184bc0d=
9cb74bb3cce1ae28a619037214a41bf4af3ee64843337
0 likes
15 replies
neilstee's avatar

@kwcham first, the token you see in the DB will never be the same because it's encrypted.

You could bcrypt('5a266d95acd1184bc0d=9cb74bb3cce1ae28a619037214a41bf4af3ee64843337') it using tinker and see if it matches the one in the DB.

kwcham's avatar

@neilstee , yes, I understand that token in db is encrypted and token in link is not. They are not the same after I encrypt the token as what you said.

Actually I tried with bcrypt('abc'); in both of my blade files, they give different encrypted value.

I did the following and it says not same (comparing the db value with link in my controller reset function),

$pass = DB::table('password_resets')->where('email', '[email protected]')->value('token');
if(Hash::check($request['token'], $pass)) {
    dd('same');
 } else {
    dd('not same');
 }
lee-van-oetz's avatar

@neilstee this wouldn't work. Runinng Hash::make($token) on same $token twice will produce different hash each time. You do have to use Hash::check($token, $hash_from_db) to test it's matching.

Snapey's avatar

please can you learn to format your code.

its quite simple, just put ``` on a line before and after your code.

that way people might be interested in helping

kwcham's avatar

Sorry, I will do that.

Updated: Already format the code, anyone can help???

Snapey's avatar

The token is usually just a long random string.... not hashed or encrypted.

kwcham's avatar

Sorry, let me restructure my questions.

I try to create the reset password feature by following Laravel 8 example. I created 2 views, one for user to enter their email and one for entering their password and confirmed password. The feature works fine until user press the button to submit the new password. The validation shows "This password reset token is invalid".

So I tried to do comparison on the token in the link and encrypted token in database by using code in my first example (Hash::check). The result is they are not the same.

I did an experiment by doing bcrypt('abc') on both of the views, they gave different output. So obviously every time bcrypting the same string will give different results.

I wonder how should I tackle this problem. Or where should I start investigating?

Snapey's avatar

you need to better understand what you are dealing with.

Tokens are plain random strings usually. They are not encrypted and they are not hashed.

Hashing and encryption are different things yet you seem to use these terms interchangeably.

since you mention email, I assume you are talking about the 'forgot password' flow and not the user changing their password.

kwcham's avatar

Sorry, maybe I am a bit confuse on encrypt and bcrypt. I meant bcrypt instead of encrypt.

You are right. The email is the one sent out to user when user click confirm when they enter their email address in order to request for reset password link. User will read this email and click the link to go to the reset password page and provide new password.

I am getting “password reset token is invalid” when user side tries to submit the new password.

Is there a way to verify that the token sent by link is the same token hashed in database?

danielsmrtns's avatar

Yes of course, it's actually from Laravel's PasswordBroker::contract, the default implementation of this contract for laravel breeze does not sync the token and the hashed token in the db by default,

The magic all happens in your PasswordResetLinkController when you call the sendResetLink method of the Password Facade

look for the code below and examine it

 $status = Password::sendResetLink(
            $request->only('email')
        );

this method is supports an optional callback as a second param; the callback receives the $user and generated $token for the password reset operation. I guess laravel expects us to utilize this optional callback to have full control of our password reset process when using laravel breeze. However, since its laravel, we are supposed to be up and running with the default configuration from laravel. But this is not so with laravel breeze. I can't say anything about Fortify and Jetstream. However, there is a workaround and its pretty easy...

I had the same issue and this is how I solved it I had to update my 'vendor\laravel\framework\src\Illuminate\Auth\Passwords\PasswordBroker.php' file In your ('vendor\laravel\framework\src\Illuminate\Auth\Passwords\PasswordBroker.php')

Step 1: Look for this line of code

$user->sendPasswordResetNotification($token); 

this is the line that sends the password reset notification email with the raw generated $token to the user. Hence, what we need to do is inject our custom code that verifies that the hashed value of the $token is the same as the one in the password_resets table; this way we would have already synced the two tokens (in db and email) Hence, immediately the email has been sent with the $token, you can grab that $token and use it for your validation

My approach was to update my db migration on the passwords_resets table and I then created a new column which stored the raw $token, this way it was easy for me to validate both (hashed and raw ) tokens from the db using eloquent. here is my code

Migration

public function up()
    {
        Schema::create('password_resets', function (Blueprint $table) {
            $table->string('email')->index();
            $table->string('token');
			//this is the raw_url_token column i added
            $table->string('raw_url_token')->nullable();
            $table->timestamp('created_at')->nullable();
        });
    }

step 2: I updated my 'vendor\laravel\framework\src\Illuminate\Auth\Passwords\PasswordBroker.php'

immediately after this line : $user->sendPasswordResetNotification($token); you place your custom code, just like i did mine

$user->sendPasswordResetNotification($token);

            // update the password_resets table using custom code
            
            DB::table('password_resets')
                ->updateOrInsert(
                    ['email' => $user->email],
                    [
                        // the raw token sent to the user's email as seen in the url
                        'raw_url_token' => $token,
                        // the token that will be saved in the db for validation
                        'token' => Hash::make($token)
                     ]
                );

this way anytime a password reset link is activated, the generated $token is consistent across both the db the email, because we are in fact working with what was sent to the user in his email(raw_url_token) and hashing it to restore it in another column (token).

and that's all.. Hope it helps you ... #Peace

1 like
danielsmrtns's avatar

A better alternative would be to copy that custom code we wrote and put it in a callback that should be used as a second parameter to the sendPasswordResetNotification() in your

App\Http\Controllers\Auth\PasswordResetLinkController file

See below:

         $status = Password::sendResetLink(
            $request->only('email'),
            fn ($user, $token) =>
                (DB::table('password_resets')
                    ->updateOrInsert(
                        ['email' => $user->email],
                        [
                            // the raw token sent to the user's email as seen in the url
                            'raw_url_token' => $token,
                            // the token that will be saved in the db for validation
                            'token' => Hash::make($token)
                        ]
                    ))  
                    ? $user->sendPasswordResetNotification($token) 
                    : null
        );

The hard-coded option is not the best solution because it will disappear after update, so with this new way of using a callback, we are sure that the code will function even after subsequent updates. Also from this code, it is evident that the task of sending the mail comes last in the source order, and the mailing of the user is dependent on the db transaction or query.

1 like
alaminjwel's avatar

In my case, the issue was the users table. I created the users table manually with my custom fields prior to installing laravel breeze and added the breeze required fields manually to table. I did not used breeze migration. That caused the issue.

Now I run breeze migration to create users table and then added my custom fields manually. It solved the issue.

lee-van-oetz's avatar

Laravel's default hashing will produce different hash each time so running bcrypt($token) on $token from email link won't tell you much. You have to use Hash::check($token, $hash_from_db) if you want to manually check match when debugging. Two things worth checking:

  1. I haven't realized password reset record actually has hash of token rather than the token itself and I was passing hash of token to frontend making request to the laravel API that was supposed to set the new password and that was naturally failing.
  2. Timezones -- make sure you're consistent with timezones or you make up for not being consistent if you're not. Default lifetime of token is an hour, so being off by one timezone in the wrong direction against DB's created at means your token will be immediately considered stale.

Please or to participate in this conversation.