Skip to content

Lesson 1 — Rails 8 authentication generator

Why not Devise?

Devise is the dominant Rails authentication solution and it’s excellent for what it does. But it generates a large amount of code you don’t own — views in engine paths, controllers you can’t easily inspect, a DSL that abstracts away what’s actually happening.

Rails 8 ships with a built-in authentication generator that takes the opposite approach: it generates readable, ownable Ruby code directly into your app. You can read every line, understand what it does, and change anything. It’s less feature-rich than Devise out of the box, but it teaches you what authentication actually involves.

For a tutorial series, ownable code wins.

Before you run the generator

The Rails 8 authentication generator assumes no User model exists. We have one — with existing associations, validations, seed data, fixtures, and tests. Running the generator naively causes conflicts. Work through this section carefully before running anything.

Step 1 — Run the generator, decline all overwrites

1
bin/rails generate authentication

When prompted to overwrite any existing file, answer n (no) to all of them. The generator still creates all the new files it needs — Session model, Authentication concern, SessionsController, PasswordsController, Current model, and mailers — without touching your existing User model, fixtures, or tests.


Step 2 — Fix the migration before running it

The generator creates two migrations — create_users and create_sessions. The create_sessions migration is fine. The create_users migration must not be run as-is — it will fail because the users table already exists.

Rename and edit it:

1
2
# Find the create_users migration
ls db/migrate/ | grep create_users

Rename the file from TIMESTAMP_create_users.rb to TIMESTAMP_add_password_digest_to_users.rb, then open it and replace the entire content with:

1
2
3
4
5
class AddPasswordDigestToUsers < ActiveRecord::Migration[8.1]
  def change
    add_column :users, :password_digest, :string, null: false, default: ""
  end
end

Now migrate:

1
bin/rails db:migrate

This adds only the password_digest column that has_secure_password needs, without touching the existing users table structure.


And this is exactly what should go in the GitHub issue as the required workaround — it’s more precise than just “delete the migration.”

Step 3 — Add generator additions to User manually

Open app/models/user.rb and add the lines the generator would have added:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class User < ApplicationRecord
  has_secure_password

  generates_token_for :password_reset, expires_in: 15.minutes do
    password_salt.last(10)
  end

  has_many :sessions, dependent: :destroy

  # ... existing code unchanged below
  validates :email, presence: true,
                    uniqueness: { case_sensitive: false },
                    format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :name, presence: true

  has_many :owned_boards, class_name: "Board",
                          foreign_key: :user_id,
                          dependent: :destroy
  has_many :memberships, dependent: :destroy
  has_many :boards, through: :memberships
end

has_secure_password adds a password virtual attribute and authenticate_by(email_address:, password:) for login. It requires the password_digest column the migration just created.

generates_token_for :password_reset creates a signed, expiring token for password reset links — no separate token column needed.

has_many :sessions lets Rails manage one session record per login, allowing users to sign out of specific devices in future.

Step 4 — Handle email vs email_address

The Rails 8 generator uses email_address throughout — the Authentication concern, SessionsController, and session forms all reference email_address. Our User model has an email column.

Rather than renaming the column (which requires a migration and updating all references throughout the app), add an alias:

1
2
3
4
class User < ApplicationRecord
  alias_attribute :email_address, :email
  # ...
end

alias_attribute makes email_address a full alias for email — reads, writes, validations, and form helpers all work against either name. The generator’s code works unchanged. Your existing code works unchanged. The database column stays email.

Step 5 — Update the fixture

The generator would have added password_digest to the users fixture. Add it manually to test/fixtures/users.yml:

1
2
3
4
5
6
7
8
9
one:
  name: Alice Smith
  email: alice@example.com
  password_digest: <%= BCrypt::Password.create("password") %>

two:
  name: Bob Jones
  email: bob@example.com
  password_digest: <%= BCrypt::Password.create("password") %>

BCrypt::Password.create generates a real bcrypt hash. Fixtures with has_secure_password need this so authenticate_by works correctly in tests.

Step 6 — Update user tests

Add authentication-related tests to test/models/user_test.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# test/models/user_test.rb
require "test_helper"

class UserTest < ActiveSupport::TestCase
  test "authenticate_by returns user with correct password" do
    user = User.create!(name: "Cindy", email: "cindy@example.com",
                        password: "password")
    assert_equal user,
                 User.authenticate_by(email_address: "cindy@example.com",
                                      password: "password")
  end

  test "authenticate_by returns nil with wrong password" do
    User.create!(name: "Cindy", email: "cindy@example.com",
                 password: "password")
    assert_nil User.authenticate_by(email_address: "cindy@example.com",
                                    password: "wrong")
  end

  test "email must be unique" do
    User.create!(name: "Cindy", email: "cindy@example.com",
                 password: "password")
    user = User.new(name: "Cindy Again", email: "cindy@example.com",
                    password: "password")
    assert_not user.valid?
  end

  test "generates password reset token" do
    user  = User.create!(name: "Cindy", email: "cindy@example.com",
                         password: "password")
    token = user.generate_token_for(:password_reset)
    assert_not_nil token
    assert_equal user, User.find_by_token_for(:password_reset, token)
  end
end

Step 7 — Verify the setup

Run the user tests before proceeding:

1
bin/rails test test/models/user_test.rb

All tests should pass. If anything fails here, fix it before moving on — the rest of the module depends on auth being correctly wired.

What the generator created

With the manual additions in place, here’s a summary of what now exists:

app/models/session.rb — a Session model that stores one record per active login. Each session has a user_agent and ip_address for security auditing.

app/models/current.rb — a CurrentAttributes store for request-scoped data:

1
2
3
4
class Current < ActiveSupport::CurrentAttributes
  attribute :session
  delegate :user, to: :session, allow_nil: true
end

CurrentAttributes is thread-safe — Current.session is set at the start of each request and cleared at the end.

app/concerns/authentication.rb — the core auth concern included in ApplicationController:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
module Authentication
  extend ActiveSupport::Concern

  included do
    before_action :require_authentication
    helper_method :authenticated?, :current_user
  end

  private

  def current_user
    Current.session&.user
  end

  def require_authentication
    resume_session || request_authentication
  end

  def resume_session
    Current.session ||= find_session_by_cookie
  end

  def find_session_by_cookie
    Session.find_by(id: cookies.signed[:session_id])
  end

  def request_authentication
    session[:return_to_after_authenticating] = request.url
    redirect_to new_session_url
  end

  def start_new_session_for(user)
    user.sessions.create!(
      user_agent: request.user_agent,
      ip_address: request.remote_ip
    ).tap do |session|
      Current.session = session
      cookies.signed.permanent[:session_id] = {
        value:     session.id,
        httponly:  true,
        same_site: :lax
      }
    end
  end

  def terminate_session
    Current.session.destroy
    cookies.delete(:session_id)
  end
end

require_authentication runs before every action. It tries to resume an existing session from the signed cookie — if none is found, it redirects to the sign in page.

start_new_session_for(user) creates a Session record, stores its id in a signed httponly cookie, and sets Current.session. The httponly flag prevents JavaScript from reading the cookie — an important security measure.

app/controllers/sessions_controller.rb — handles sign in and sign out. app/controllers/passwords_controller.rb — handles password reset.

Both already have allow_unauthenticated_access so they’re accessible without being logged in.

Remove the current_user stub

Now that Authentication provides a real current_user, remove the stub from ApplicationController:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include Authentication
  include ToastHelper

  def current_user
    Current.session&.user
  end

  helper_method :current_user
end

The stub def current_user = User.first is gone. current_user now comes from the Authentication concern via Current.session&.user.

before_action :require_authentication now runs on every request. Unauthenticated users are redirected to the sign in page — which means the sign in and sign up pages themselves must be accessible without auth. We handle that in the next lesson.