Rails 8 Authentication

By Exequiel Rozas

- January 27, 2025

If you've dealt with authentication in a Rails app before, you've probably used a gem like devise to add the feature in a couple of hours and move on to the next thing.

However, the release of Rails 8 came with many changes that philosophically nudge the framework to developer empowerment to reduce the need for third-party code.

That's why Rails 8 comes with an authentication generator to help us add the feature to any application pretty quickly while following Rails conventions and owning the authentication code.

In this article, we will add authentication to a Rails app using the generator and some custom code on top of it to improve the default provided auth.

What we will build

We will build a simple job listing application where listing details can only be accessed by authenticated users.

Beyond e-mail & password authentication, we will also add an email and password sign up flow

Even though this is not as feature-complete as devise, it is a step closer to showing that we can add as many features as we require as long as we build upon the foundations and concepts provided by the auth generator.

The final result should look something like this:

Application set up

The first step is to create a new Rails app using Tailwind:

$ rails new joblist --css=tailwind

Next, we run the db setup and migrations:

$ bin/rails db:setup db:migrate

We will have a single model called JobListing so we add it:

$ bin/rails generate scaffold JobListing name description:text

Next, we migrate the database:

$ bin/rails db:migrate

After running this, and adding some custom styling, we end up with something like this:

Of course, this is just a placeholder for the home page. We will be adding the rest of the functionality as we progress with the tutorial.

Now, we will analyze the generated code to better understand the auth flow. You can skip to the tutorial itself if you already know about the code.

Understanding the generator

The first thing to understand is that Rails 8 authentication is not meant to be a stand-in replacement for something like devise, at least not for now.

On the contrary, it's meant to kickstart our app's authentication and leave us with a solid foundation to build starting from some solid architectural choices.

Out of the box, it comes with two main features: user sign in and password recovery.

This leaves us with the task of building our sign-up flow and any other feature we want like social login, magic links login, phone authentication, account confirmation, account locking, etc.

If this is a dealbreaker for you, you might consider using a gem.

But, consider that 'owning' the authentication flow means that customization will be easier, and believe me: it's very probable that your auth flows will require customization down the road.

Now that we have that out of the way, let's focus on the architecture:

The generator's architecture

When we run the bin/rails generate authentication command, Rails generates quite a bit of code for us: controllers, controller concerns, views, mailers, and even a Connection channel to use with ActionCable.

However, to understand how everything works we must focus on the models it generates: User, Session and Current. The first two are db-backed:

User and session models database schema

Out of the box, the users table stores an e-mail address and a password_digest which is used to compare the password provided at sign-in with the password used to create the user.

On the other hand, the sessions table belongs to a User and, out of the box, stores the ip_address and user_agent information for every session started by the User which is useful by itself but also allows us to build upon this concept in the future if we need to.

The Current model is a singleton that inherits from ActiveSupport::CurrentAttributes that lets us access the user associated with the session present in a given request cookies:

class Current < ActiveSupport::CurrentAttributes
  attribute :session
  delegate :user, to: :session, allow_nil: true
end

Whenever we call Current.user we access the User instance associated with the session via the user_id foreign key in the sessions table.

This is very similar to the view-accessible current_user global method defined by devise so we can use it in our views or controllers whenever we need to check whether a request comes from a logged-in user or not.

One advantage the Rails 8 auth generator has over devise is that it keeps track of sessions history instead of just storing the latest session information like the trackable module does. This allows for a more granular control of user sessions and possibly a more secure application in general.

The Authentication concern

The heart of the authentication flow with the Rails 8 generator is definitely the Authentication controller concern.

This module, which is 52 lines of code, adds everything we need to authenticate users within our application.

It defines two important methods which are require_authentication and the helper method authenticated?.

As its name suggests, the require_authentication method makes sure the request has a signed cookie that defines the session_id which is used to retrieve a session or return the Current.session in case the session was previously defined in the request lifecycle.

On the other hand, the authenticated? method is an alias to the resume_session method, which returns the current session or finds a session by cookie.

There's also a private terminate_session method which basically destroys the Current.session and deletes the session_id cookie to make sure the user is currently signed out of our application.

One thing to consider with this concern is that it handles the typical redirect after sign in: it keeps track of the URL that generated the sign in request in the return_to_after_authenticating cookie and saves us from manually defining that common redirection flow.

Because the Authentication concern is included in the ApplicationController by default, authentication is required for every endpoint of our application that inherits from it. If we want to skip authentication for a controller action we need to add an allow_unauthenticated_access only: [:action] and the before_action :resume_session calls to every controller action we need to skip authentication for, or we can add the allow_unauthenticated_access call after the include Authentication in the ApplicationController and manually define the routes we want to authenticate.

The sessions controller

This controller is responsible for creating and destroying sessions. In other words: it's responsible for signing users in and out of our application.

It defines three actions:

  • The new action, which is responsible for rendering the sign-in form.
  • The create action, which authenticates a user and persists the session in the database.
  • The destroy action, which is responsible for terminating a session whenever a user wants to sign out of our application.

Note that, to make this all work, a single resource :session is defined in the routes file which defines 6 endpoints. However, the show and edit actions are not defined in the controller, so we can modify the route to resource :session, only: [:new, :create, :destroy] to keep our routes cleaner.

The passwords controller

This controller is responsible for the password recovery flow.

It defines four actions:

  • The new action: responsible for rendering the password recovery form.
  • The create action: responsible for sending the password reset email using the PasswordsMailer.reset.
  • The edit action: renders the password edit form if the correct token is present in the URL. The token is a signed JWT which contains the user's ID, and it has a 15-minute expiration date. If the token is not present or is invalid, the user gets redirected to the new action.
  • The update action is where the actual update happens given the password and its confirmation match.

If we want to customize the password reset e-mail appearance, we can do it by changing app/views/passwords_mailer/reset.html.erb.

The routes

The generator adds the following to our routes.rb file:

# config/routes.rb
resource :session, only: %i[new create destroy]
resources :passwords, param: :token

This generates the necessary routes for the sign-in and password reset flows.

However, to remove unused routes, we can modify the passwords routes:

resources :passwords, only: %i[new create edit update], param: :token

We don't really need to access the index, show or destroy actions for this resource in particular.

The views

The generator creates the necessary views for the sign-in and password recovery flows.

Namely, it generates views for the sign in at app/views/sessions/new.html.erb and another for the password recover at app/views/passwords/new.html.erb.

After minimal styling, adding a padding, borders and centering them, is applied they look like this:

The generated sign-in view

The generated password recovery view

We also get a reset mailer view generated at app/views/password_mailer/reset.html.erb which might not the prettiest, but it does its job:

The generated reset mailer view

Sign in flow

The first step to add authentication with the Rails generator is to actually run it:

$ bin/rails generate authentication

This will generate all the necessary code and database migrations to add a sign-in feature to our application.

Note that if we created our app with the --css=tailwind argument, the views will be decently styled to kickstart the process.

The command will create a User and Session models and a Current class to access the current user whenever we need it.

Bear in mind that the generator produces a User model and that, at least for the moment, we're unable to customize which model to use. If you would like to use another model for authentication, you would have to change the references to users in the generated code and add the has_many :sessions association and the has_secure_password call to the desired model to implement password-related logic.

It will also add endpoints, controllers and their corresponding views to handle session creation (sign-in) and the password recovery flows.

Unless we need to modify the custom sign-in flow with the addition of extra fields or any other personalization, we're basically good to go: users can log in to our application using their existing account information.

However, consider that, by default, every route in your application will be protected, and you might not want that.

To allow a specific endpoint or controller to be accessed without authentication, we can add the following to the controller file:

# app/controllers/pages_controller.rb
class PagesController < ApplicationController
  allow_unauthenticated_access only: [:home]
  before_action :resume_session, only: [:home]

  def home
    @job_listings = JobListing.all
  end
end

Notice that the resume_session method call is necessary for this to work because it's the method required to retrieve the current session. Otherwise, even if we were signed in the view wouldn't be able to access the Current.user.

To add a sign-out action we need to add a button that makes a DELETE request to the /session endpoint.

Somewhere in our app, we should add something like this:

<% if Current.user.present? %>
  <%= button_to "Sign out", session_path, method: :delete, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-black underline focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
<% else %>
  <%= link_to "Sign in", new_session_path, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
<% end %>

Notice that we have to add the button_to helper for it to work correctly.

Flash messages

Even though some actions like sessions#create include a flash message for the scenario where no user is found, there are a couple of places were flash messages can help our users.

The first place is in the request_authentication method that resides in the Authentication concern:

# app/controllers/concerns/authentication.rb
def request_authentication
  session[:return_to_after_authenticating] = request.url
  flash[:alert] = "You must be logged in to access this page"
  redirect_to new_session_path
end

We add the flash message to notify our users that they must be logged to access a given page.

The other place is in the destroy action of the SessionsController:

# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def destroy
    terminate_session
    flash[:notice] = "You have been signed out."
    redirect_to new_session_path
  end
end

We then add a partial to display flash messages to give better feedback to our users.

After adding the flash messages, we should have a working authentication flow:

Password recovery flow

The password reset flow also comes out of the box with the Rails 8 auth generator.

To test it in development, we can add the letter_opener gem to our Gemfile and configure ActionMailer to intercept emails:

# Gemfile
group :development do
  gem "letter_opener"
end
# config/development.rb
config.action_mailer.delivery_method = :letter_opener
config.action_mailer.perform_deliveries = true

We can access the password reset screen at /passwords/new and a link to it is actually present in the views provided by the generator.

When a user clicks in the link received in the password reset email, which contains a signed and expiring token, the password edit form gets rendered, and it allows the user to update the password.

The password flow looks like the following:

Sign up flow

The Rails authenticator doesn't include a sign-up flow, mostly because it's a very application-dependent flow.

But, adding a user sign-up flow to our app is actually not that difficult.

Before anything, we need to add validations to our User model, considering that the default auth generator doesn't any.

By default, we need an email_address and a password to sign in to our application, so we need to make sure both of those fields are required and valid.

class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy

  validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :password, presence: true

  normalizes :email_address, with: ->(e) { e.strip.downcase }
end

Then, we add a registration endpoint:

# config/routes.rb
resource :registrations, only: [:new, :create]

Next, we define a RegistrationsController and define an action to render the sign-up form:

# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
  allow_unauthenticated_access only: %i[new create]
  resume_session only: :new

  def new
    @user = User.new
  end
end

We then add a view to handle user sign up, we will have fields for email, password, and password confirmation:

# app/views/registrations/new.html.erb
<div class="mt-24">
  <div class="max-w-2xl mx-auto md:w-2/3 w-full">
    <div class="px-6 py-4 border border-gray-300 rounded-xl">
      <h1 class="font-bold text-2xl">Sign up</h1>
      <p class="text-base text-gray-500">
        Sign up to create your account and start posting jobs.
      </p>

      <%= form_with url: registrations_url, model: @user, method: :post, class: "contents" do |form| %>
        <div class="my-5">
          <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
        </div>

        <div class="my-5">
          <%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
        </div>

        <div class="my-5">
          <%= form.password_field :password_confirmation, required: true, autocomplete: "current-password", placeholder: "Password confirmation", maxlength: 72, class: "block shadow rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
        </div>

        <div class="col-span-6 sm:flex sm:items-center sm:gap-4">
          <div class="flex w-full items-center justify-between">
            <div class="inline">
              <%= form.submit "Sign up", class: "rounded-lg py-2 px-6 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
            </div>

            <div class="mt-4 text-sm text-gray-500 sm:mt-0">
              <%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline" %>
            </div>
          </div>
        </div>
      <% end %>
    </div>
  </div>
</div>

Then, we add the sign-up logic to the create action of the registrations controller:

class RegistrationsController < ApplicationController
  allow_unauthenticated_access only: %i[new create]
  before_action :resume_session, only: %i[new create]

  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)
    if @user.save
      start_new_session_for @user
      redirect_to root_path, notice: "You've successfully signed up to Joblister. Welcome!"
    else
      populate_flash_with_errors(@user)
      render :new, status: :unprocessable_entity
    end
  end

  private

  def user_params
    params.expect(user: [:email_address, :password, :password_confirmation])
  end

  def populate_flash_with_errors(user)
    if password_confirmation_matches? && user.errors.any?
      flash[:alert] = user.errors.full_messages.join(", ")
    elsif !password_confirmation_matches?
      flash[:alert] = "Password and password confirmation don't match"
    end
  end

  def password_confirmation_matches?
    user_params[:password] == user_params[:password_confirmation]
  end
end

Now, if we go through the sign-up flow, we can register users within our application.

Of course, this flow can be vastly different depending on your application's requirements, I just wanted to demonstrate how a basic devise-like sign-up flow would work.

You can modify the view or the controller action as you wish in case your application calls for more fields or a different sign-up flow.

If you've been following, the sign-up flow should look something like this:

TL;DR

Rails 8 added an authentication generator that, in principle, allows us to get rid of gems like devise and build auth by our own.

It generates two db-backed models: User and Session which help us keep track of the actual users and their session

It also generates a Current class that inherits from ActiveSupport::CurrentAttributes that allows us to access the currently logged-in user using the Current.user method.

Besides the models it also adds an Authentication concern that handles most of the logic and the corresponding controllers, endpoints, and views to handle the basic sign-in and password recovery flows.

After we run the generator, we should be able to sign in or reset passwords for any existing user in our application.

To have a better UX, we should also add flash messages to the request_authentication method in the Authentication concern and to the destroy session endpoint.

To add a sign-up flow, we just need to create a RegistrationsController with its corresponding endpoint and view to render the form where users can input their data and sign-up to our application.

After creating the user we just call the start_new_session_for method and pass the User instance to log the user in.

As you can see, the Rails 8 auth generator produces a practical way to handle user authentication and allows us to replace gems like devise which can be pretty heavy, especially if our auth needs are not that advanced.

Having to manage the code by ourselves can seem daunting at first but, eventually, it probably results in a more maintainable application because we are forced to understand what's happening behind the scenes.

I hope this article helped you understand and improve your authentication code. Stay in tune for more tutorials like this.

Have a good one and happy coding!

Build your next rails app 10x faster with Avo

Avo dashboard showcasing data visualizations through area charts, scatterplot, bar chart, pie charts, custom cards, and others.

Find out how Avo can help you build admin experiences with Rails faster, easier and better.