Rails 8 Authentication from Scratch

Rails 8 ships with a built-in authentication generator that creates a solid foundation for user login systems. This eliminates the need for Devise in many applications while providing full control over the authentication flow.

The Rails 8 Authentication Generator

Rails 8 introduced bin/rails generate authentication, which scaffolds a complete session-based authentication system. Unlike Devise, this generates plain Ruby code that lives in the application—readable, modifiable, and free of hidden magic.

Run the generator to create the foundation:

# Terminal
bin/rails generate authentication
bin/rails db:migrate

This creates several files: a User model with secure password handling, a Session model for tracking logins, controllers for registration and authentication, and the necessary views. The generated code uses Rails 8's has_secure_password with modern defaults.

Understanding the Generated User Model

The generator produces a User model with password handling and session management built in:

# app/models/user.rb
class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy

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

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

  # Generate a password reset token that expires
  generates_token_for :password_reset, expires_in: 15.minutes do
    password_salt&.last(10)
  end

  # Generate an email verification token
  generates_token_for :email_verification, expires_in: 24.hours do
    email_address
  end
end

The generates_token_for method is a Rails 8 addition that creates secure, expiring tokens tied to model state. When the password changes (affecting password_salt), existing reset tokens automatically invalidate.

The Session Controller Pattern

Authentication flows through a SessionsController that creates and destroys sessions:

# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  allow_unauthenticated_access only: [:new, :create]
  rate_limit to: 10, within: 3.minutes, only: :create,
             with: -> { redirect_to new_session_path, alert: "Try again later." }

  def new
  end

  def create
    if user = User.authenticate_by(email_address: params[:email_address],
                                    password: params[:password])
      start_new_session_for(user)
      redirect_to after_authentication_url
    else
      redirect_to new_session_path, alert: "Invalid email or password."
    end
  end

  def destroy
    terminate_session
    redirect_to new_session_path, notice: "Signed out."
  end

  private

  def after_authentication_url
    root_path
  end
end

Notice the built-in rate_limit directive—Rails 8 includes rate limiting without additional gems. The authenticate_by method performs constant-time comparison to prevent timing attacks.

The Authentication Concern

The generator creates a concern that handles session management across the application:

# app/controllers/concerns/authentication.rb
module Authentication
  extend ActiveSupport::Concern

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

  class_methods do
    def allow_unauthenticated_access(**options)
      skip_before_action :require_authentication, **options
    end
  end

  private

  def authenticated?
    resume_session.present?
  end

  def current_user
    Current.user
  end

  def require_authentication
    resume_session || request_authentication
  end

  def resume_session
    return if Current.session.present?

    if session_record = find_session_by_cookie
      set_current_session(session_record)
    end
  end

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

  def request_authentication
    session[:return_to_after_authenticating] = request.url
    redirect_to new_session_path, alert: "Please sign in."
  end

  def start_new_session_for(user)
    user.sessions.create!(user_agent: request.user_agent,
                          ip_address: request.remote_ip).tap do |session_record|
      set_current_session(session_record)
    end
  end

  def set_current_session(session_record)
    Current.session = session_record
    cookies.signed.permanent[:session_id] = {
      value: session_record.id,
      httponly: true,
      same_site: :lax
    }
  end

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

This concern uses the Current pattern to store the authenticated session and user. The allow_unauthenticated_access class method provides a clean API for controllers that need public access.

Adding Remember Me Functionality

Extend the generated code to support "remember me" tokens for persistent sessions:

# app/models/user.rb (add to existing model)
class User < ApplicationRecord
  # ... existing code ...

  generates_token_for :remember_me, expires_in: 2.weeks
end

# app/controllers/sessions_controller.rb (modify create action)
def create
  if user = User.authenticate_by(email_address: params[:email_address],
                                  password: params[:password])
    start_new_session_for(user)
    remember_user(user) if params[:remember_me] == "1"
    redirect_to after_authentication_url
  else
    redirect_to new_session_path, alert: "Invalid email or password."
  end
end

private

def remember_user(user)
  cookies.signed.permanent[:remember_token] = {
    value: user.generate_token_for(:remember_me),
    httponly: true,
    same_site: :lax
  }
end

# app/controllers/concerns/authentication.rb (add to resume_session)
def resume_session
  return if Current.session.present?

  if session_record = find_session_by_cookie
    set_current_session(session_record)
  elsif user = find_user_by_remember_token
    start_new_session_for(user)
  end
end

def find_user_by_remember_token
  return unless cookies.signed[:remember_token]
  User.find_by_token_for(:remember_me, cookies.signed[:remember_token])
end

Handling Password Resets

The token generation already exists in the User model. Build the controller to use it:

# app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
  allow_unauthenticated_access
  rate_limit to: 5, within: 1.hour, only: :create

  def new
  end

  def create
    if user = User.find_by(email_address: params[:email_address])
      PasswordMailer.reset(user).deliver_later
    end
    # Always show success to prevent email enumeration
    redirect_to new_session_path, notice: "Check your email for reset instructions."
  end

  def edit
    @user = User.find_by_token_for(:password_reset, params[:token])
    redirect_to new_password_reset_path, alert: "Invalid or expired link." unless @user
  end

  def update
    @user = User.find_by_token_for(:password_reset, params[:token])

    if @user&.update(password_params)
      @user.sessions.where.not(id: Current.session&.id).destroy_all
      redirect_to new_session_path, notice: "Password updated. Please sign in."
    else
      render :edit, status: :unprocessable_entity
    end
  end

  private

  def password_params
    params.require(:user).permit(:password, :password_confirmation)
  end
end

The find_by_token_for method returns nil for expired or invalid tokens, and the block in generates_token_for ensures tokens invalidate when the password changes.

Common Security Enhancements

Add session tracking to let users see and revoke active sessions:

# app/models/session.rb
class Session < ApplicationRecord
  belongs_to :user

  scope :active, -> { where("created_at > ?", 2.weeks.ago) }
  scope :other_than, ->(session) { where.not(id: session.id) }

  def current?(current_session)
    id == current_session&.id
  end

  def device_name
    agent = UserAgent.parse(user_agent)
    "#{agent.browser} on #{agent.os}"
  rescue
    "Unknown device"
  end
end

# app/controllers/sessions_controller.rb (add index and destroy_other)
def index
  @sessions = current_user.sessions.active.order(created_at: :desc)
end

def destroy_all_others
  current_user.sessions.other_than(Current.session).destroy_all
  redirect_to sessions_path, notice: "Other sessions terminated."
end

Summary

Rails 8 authentication provides a clean, understandable foundation that covers most application needs. The generated code handles secure password storage, session management, rate limiting, and token generation—all without external dependencies. Extend it with remember-me tokens, password resets, and session management as needed. For applications requiring OAuth or multi-factor authentication, consider adding targeted gems rather than replacing the entire system.

10 claps
← Back to Blog