Reset Password in Rails from Scratch
As a continuation of Rails Authentication from Scratch, let's add a password reset feature to our application.
Generating the Reset Password Token
The general flow of a password reset is as follows:
- User clicks "Forgot Password" link
- User enters email address
- User receives email with a link to reset password
- User clicks link and is taken to a form to enter a new password
- User enters new password.
- User submits form and password is updated.
That's the happy path. To make this happen we need to create unique secure links that are sent to the email address they entered that can load a form keyed to the user that generated the link. We'll use the SecureRandom
library to generate a random token that we can use as a unique identifier for the password reset.
The first step is to add a password_reset_token
column to our users
table. We can do this with a migration:
rails g migration add_password_reset_token_to_users password_reset_token:string password_reset_token_expires_at:datetime
With the password_reset_token_expires_at
, you can add a layer of security expiring the token after a certain amount of time.
When the user requests to reset their password, we'll populate these fields in the model.
PasswordReset
Let's build a virtual model, basically a Ruby class to encapsulate this functionality.
class PasswordReset
include ActiveModel::Model
attr_accessor :user, :email
def save
@user = User.find_by(email: email)
if @user
@user.password_reset_token = SecureRandom.urlsafe_base64
@user.password_reset_token_expires_at = 24.hours.from_now
@user.save
UserMailer.password_reset(@user).deliver_later
end
end
def self.find_by_valid_token(token)
User.where("password_reset_token = ? AND password_reset_token_expires_at > ?", token, Time.now).first
end
end
As an ActiveModel
we can use this class with form_for
. The class basically does two things, it will generate the required token and expiration date when we make it and it will handle finding a user by a valid token. Using a new model that isn't backed by a database table is fine. By doing this we can keep our User
class free from this functionality but still have a nice object for our forms and our controllers.
PasswordResetsController
We'll use a PasswordResetsController
for all the functionality with:
GET /password_resets/new
as a route to a form to request a reset password link.POST /password_resets
as a route to create the password reset and send out the email.GET /password_resets/:id/edit
where:id
is the valid password reset token and present the user with a form to reset their password.PATCH /password_resets/:id/
to set the new password if the password reset token is valid.
We can create these routes RESTfully with resources :password_resets, only: [:new, :create, :edit, :update]
in config/routes.rb
.
Requesting a Password Reset
Let's build the form to request a password reset. Because we have the PasswordReset
model, we can create an instance to wrap a form around.
app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
def new
@password_reset = PasswordReset.new
end
end
app/views/password_resets/new.html.erb
<%= render_form_for(@password_reset) do |f| %>
<div class="grid gap-2">
<div class="grid gap-1">
<%= f.label :email, class: "sr-only" %>
<%= f.email_field :email, placeholder: "name@example.com",
autocomplete: :email,
autocorrect: "email",
autocapitalize: "none" %>
</div>
<%= f.submit "Reset Password" %>
</div>
<% end %>
That allows for a user to enter their email and will submit a POST /password_resets
. And remember, render_form_for
is just a wrapper for shadcn-ui
around form_for
, so it behaves the same.
When this form is submitted, the create#passwordresets
will work like:
app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
def new
@password_reset = PasswordReset.new
end
def create
@password_reset = PasswordReset.new(password_reset_params)
@password_reset.save
flash[:notice] = "A link to reset your password has been sent to your email."
redirect_to root_url
end
end
PasswordReset#save
will:
class PasswordReset
include ActiveModel::Model
attr_accessor :user, :email
def save
@user = User.find_by(email: email)
if @user
@user.password_reset_token = SecureRandom.urlsafe_base64
@user.password_reset_token_expires_at = 24.hours.from_now
@user.save
UserMailer.password_reset(@user).deliver_later
end
end
end
So it will set the password_reset_token
to a secure random string that expires in 24 hours. It will also send out the email. Let's generate that now:
rails g mailer user
class UserMailer < ApplicationMailer
def password_reset(user)
@user = user
mail(to: @user.email, subject: "Reset Your Password")
end
end
And the mailer template:
app/views/user_mailer/password_reset.html.erb
<p>Hello,</p>
<p>Someone has requested a link to change your password. You can do this through the link below.</p>
<p><%= link_to 'Change my password', edit_password_reset_url(@user.password_reset_token) %></p>
<p>If you didn't request this, please ignore this email.</p>
<p>Your password won't change until you access the link above and create a new one.</p>
The mailer template will use the password_reset_token
to create a URL edit_password_reset_url(@user.password_reset_token)
. We'll use that URL to validate and find the password reset in PasswordResetsController#edit
and PasswordResetsController#update
.
Together all this creates the request password reset flow.
Mailers in Development
I find the best way to test mailers like this in development is to use the letter opener gem. Once that is added to your Gemfile, you can set:
config.action_mailer.perform_caching = false
config.action_mailer.raise_delivery_errors = true
config.action_mailer.perform_deliveries = true
config.action_mailer.default_url_options = {host: "localhost", port: 3000}
config.action_mailer.delivery_method = :letter_opener
In config/development.rb
. Then when you test this flow in development, the email with the link will open in a new browser tab and you can click it and continue the flow as described below.
Resetting the Password
The first step is to build GET /password_resets/:id/edit
. We'll use PasswordReset.find_by_valid_token
that we created to find the user for the valid password reset token.
class PasswordReset
# Rest of Model...
def self.find_by_valid_token(token)
User.where("password_reset_token = ? AND password_reset_token_expires_at > ?", token, Time.now).first
end
end
The find_by_valid_token
uses SQL to find the matching token and ensure that it's valid given the current time. Let's implement this in our PasswordResetsController#edit
.
app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
# Rest of Controller...
def edit
@user = PasswordReset.find_by_valid_token(params[:id])
if @user
else
flash[:alert] = "Your password reset link is not valid."
redirect_to new_password_reset_path
end
end
If we can find a user by the token in the URL, we'll render the edit form, which will present them with the ability to change their password, otherwise, we'll redirect them to make a new request for a password reset link. Here's what the edit form looks like:
app/views/password_resets/edit.html.erb
<%= render_form_for(@user, url: password_reset_path, method: :patch) do |f| %>
<div class="grid gap-2">
<div class="grid gap-1">
<div class="grid gap-1">
<%= f.label :password, class: "sr-only" %>
<%= f.password_field :password, placeholder: "Your password...",
autocomplete: "current-password" %>
</div>
<div class="grid gap-1">
<%= f.label :password, class: "sr-only" %>
<%= f.password_field :password_confirmation, placeholder: "Confirm your password...",
autocomplete: "current-password" %>
</div>
<%= f.submit "Reset Password" %>
</div>
</div>
<% end %>
The URL of the form will be a PATCH
request password_reset_path
creating a submission to PATCH /password_resets/:id/
which will route to PasswordResetsController#update
. In that action we'll find the user the same way we did in edit and accept the fields from the form to update the password if a user was found. The entire controller now looks like:
app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
def new
@password_reset = PasswordReset.new
end
def create
@password_reset = PasswordReset.new(password_reset_params)
@password_reset.save
flash[:notice] = "A link to reset your password has been sent to your email."
redirect_to root_url
end
def edit
@user = PasswordReset.find_by_valid_token(params[:id])
if @user
else
flash[:alert] = "Your password reset link is not valid."
redirect_to new_password_reset_path
end
end
def update
@user = PasswordReset.find_by_valid_token(params[:id])
if @user
if @user.update(user_params)
flash[:notice] = "Your password has been updated."
redirect_to root_url
else
flash.now[:alert] = "There was an error updating your password."
render :edit, status: :unprocessable_entity
end
else
flash[:error] = "Your password reset link is not valid."
redirect_to new_password_reset_path
end
end
private
def password_reset_params
params.require(:password_reset).permit(:email)
end
def user_params
params.require(:user).permit(:password, :password_confirmation)
end
end
Conclusion
The key is managing the password_reset_token
via SecureRandom
. The rest is just patterns on top of Rails controllers, views, and mailers. Once again, it's not that hard to build a secure password reset yourself, it just takes a bit of wiring up.
In the next post, we'll build the ability to login via a magic link which is a pretty similar implementation to this if you could guess.
Subscribe to my newsletter
Read articles from Avi Flombaum directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Avi Flombaum
Avi Flombaum
I'm an engineer, educator, and entrepreneur that has been building digital products for over 20 years with experience in startups, education, technical training, and developer ecosystems. I founded Flatiron School in NYC where I taught thousands of people how to code. Previously, I was the founder of Designer Pages. Currently I'm the Chief Product Officer at Revature.