Ruby on Rails Authentication Tutorial: Registration, Login, My Account, and Logout

Ruby on Rails Authentication Tutorial: Registration, Login, My Account, and Logout

AI Reading

Quick summary of this article

Preparing the AI summary…

Authentication is one of the first real features most Rails applications need. In this tutorial, we will build a complete email-and-password authentication flow without adding a large authentication framework. The finished application supports user registration, login, a protected My Account page, profile editing, and logout.

We will use Rails sessions for authentication, has_secure_password for BCrypt password hashing, Bootstrap 5 for the interface, and request tests for the critical flows.

What we will build

  • Register: create an account with name, email, password, and confirmation.
  • Login: authenticate with a normalized email and password.
  • My Account: display and edit only the currently signed-in user.
  • Logout: securely remove the session and return to the login page.
  • Route protection: redirect guests away from account pages.

Prerequisites

  • Ruby 3.2 or newer
  • Rails 7.1 or Rails 8
  • SQLite for development
  • Basic knowledge of Rails MVC and ERB

Step 1: Create the Rails application

rails new rails_auth_demo
cd rails_auth_demo
bundle install
rails db:create

Add BCrypt to the Gemfile. New Rails applications commonly include this line commented out, so remove the leading # if it is already present.

gem "bcrypt", "~> 3.1"
bundle install

Step 2: Generate the User model

rails generate model User first_name:string last_name:string email:string password_digest:string
rails db:migrate

For production-quality uniqueness, add a database index as well as a model validation.

rails generate migration AddUniqueIndexToUsersEmail

Edit the generated migration:

# db/migrate/XXXXXXXXXXXXXX_add_unique_index_to_users_email.rb
class AddUniqueIndexToUsersEmail < ActiveRecord::Migration[7.1]
  def change
    add_index :users, :email, unique: true
  end
end
rails db:migrate

Step 3: Build the User model

# app/models/user.rb
class User < ApplicationRecord
  has_secure_password

  before_validation :normalize_email

  validates :first_name, presence: true, length: { maximum: 50 }
  validates :last_name, presence: true, length: { maximum: 50 }
  validates :email,
            presence: true,
            length: { maximum: 255 },
            format: { with: URI::MailTo::EMAIL_REGEXP },
            uniqueness: { case_sensitive: false }
  validates :password, length: { minimum: 8 }, allow_nil: true

  def full_name
    "#{first_name} #{last_name}".strip
  end

  private

  def normalize_email
    self.email = email.to_s.strip.downcase
  end
end

has_secure_password stores a password digest, provides password and password_confirmation virtual attributes, and adds the authenticate method. Never store plain-text passwords.

Step 4: Configure authentication routes

# config/routes.rb
Rails.application.routes.draw do
  root "accounts#show"

  get  "/register", to: "registrations#new",    as: :register
  post "/register", to: "registrations#create"

  get    "/login",  to: "sessions#new",         as: :login
  post   "/login",  to: "sessions#create"
  delete "/logout", to: "sessions#destroy",     as: :logout

  get   "/account",      to: "accounts#show",   as: :account
  get   "/account/edit", to: "accounts#edit",   as: :edit_account
  patch "/account",      to: "accounts#update"
end

Notice that the URL never accepts a user ID for My Account. The server always loads the user from the session, preventing one user from changing another user’s account by altering a URL.

Step 5: Add authentication helpers

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  helper_method :current_user, :logged_in?

  private

  def current_user
    @current_user ||= User.find_by(id: session[:user_id])
  end

  def logged_in?
    current_user.present?
  end

  def require_login
    return if logged_in?

    session[:return_to] = request.fullpath if request.get?
    redirect_to login_path, alert: "Please log in to continue."
  end

  def redirect_if_authenticated
    redirect_to account_path, notice: "You are already logged in." if logged_in?
  end
end

find_by safely returns nil if a session contains an old or deleted user ID. The require_login method protects private pages, while redirect_if_authenticated prevents signed-in users from seeing registration and login screens.

Step 6: Create the registration controller

# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
  before_action :redirect_if_authenticated

  def new
    @user = User.new
  end

  def create
    @user = User.new(registration_params)

    if @user.save
      reset_session
      session[:user_id] = @user.id
      redirect_to account_path, notice: "Welcome! Your account was created."
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def registration_params
    params.require(:user).permit(
      :first_name,
      :last_name,
      :email,
      :password,
      :password_confirmation
    )
  end
end

Calling reset_session before assigning the new user ID protects against session fixation. A successful registration signs the user in immediately.

Step 7: Create the sessions controller

# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  before_action :redirect_if_authenticated, only: %i[new create]

  def new
  end

  def create
    email = params[:email].to_s.strip.downcase
    user = User.find_by(email: email)

    if user&.authenticate(params[:password])
      destination = session.delete(:return_to)
      reset_session
      session[:user_id] = user.id

      redirect_to(destination.presence || account_path,
                  notice: "Logged in successfully.")
    else
      flash.now[:alert] = "Invalid email or password."
      render :new, status: :unprocessable_entity
    end
  end

  def destroy
    reset_session
    redirect_to login_path, status: :see_other,
                            notice: "You have been logged out."
  end
end

The error message deliberately does not reveal whether the email exists. Logout uses a DELETE request and responds with HTTP 303, which avoids accidental form resubmission.

Step 8: Create the My Account controller

# app/controllers/accounts_controller.rb
class AccountsController < ApplicationController
  before_action :require_login
  before_action :set_user

  def show
  end

  def edit
  end

  def update
    attributes = account_params
    attributes = attributes.except(:password, :password_confirmation) if attributes[:password].blank?

    if @user.update(attributes)
      redirect_to account_path, notice: "Account updated successfully."
    else
      render :edit, status: :unprocessable_entity
    end
  end

  private

  def set_user
    @user = current_user
  end

  def account_params
    params.require(:user).permit(
      :first_name,
      :last_name,
      :email,
      :password,
      :password_confirmation
    )
  end
end

Blank password fields are removed during profile updates, allowing users to update their name or email without changing their password.

Step 9: Add Bootstrap and the navigation layout

Replace the application layout with the following:

<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <title>Rails Auth Demo</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
          rel="stylesheet">
    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>

  <body class="bg-light">
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
      <div class="container">
        <%= link_to "Rails Auth Demo", root_path, class: "navbar-brand" %>

        <div class="ms-auto d-flex gap-2">
          <% if logged_in? %>
            <%= link_to "My Account", account_path, class: "btn btn-outline-light" %>
            <%= button_to "Logout", logout_path,
                          method: :delete,
                          class: "btn btn-warning" %>
          <% else %>
            <%= link_to "Register", register_path, class: "btn btn-outline-light" %>
            <%= link_to "Login", login_path, class: "btn btn-primary" %>
          <% end %>
        </div>
      </div>
    </nav>

    <main class="container py-4">
      <% flash.each do |type, message| %>
        <% css = type.to_s == "notice" ? "success" : "danger" %>
        <div class="alert alert-<%= css %> alert-dismissible fade show">
          <%= message %>
          <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
        </div>
      <% end %>

      <%= yield %>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
  </body>
</html>

Step 10: Create a reusable error partial

<!-- app/views/shared/_errors.html.erb -->
<% if object.errors.any? %>
  <div class="alert alert-danger">
    <h2 class="h6">
      <%= pluralize(object.errors.count, "error") %> prevented this form from being saved:
    </h2>
    <ul class="mb-0">
      <% object.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
    </ul>
  </div>
<% end %>

Step 11: Build the registration page

<!-- app/views/registrations/new.html.erb -->
<div class="row justify-content-center">
  <div class="col-lg-6">
    <div class="card shadow-sm border-0">
      <div class="card-body p-4">
        <h1 class="h3 mb-4">Create your account</h1>
        <%= render "shared/errors", object: @user %>

        <%= form_with model: @user, url: register_path do |form| %>
          <div class="row">
            <div class="col-md-6 mb-3">
              <%= form.label :first_name, class: "form-label" %>
              <%= form.text_field :first_name, class: "form-control", required: true %>
            </div>
            <div class="col-md-6 mb-3">
              <%= form.label :last_name, class: "form-label" %>
              <%= form.text_field :last_name, class: "form-control", required: true %>
            </div>
          </div>

          <div class="mb-3">
            <%= form.label :email, class: "form-label" %>
            <%= form.email_field :email, class: "form-control",
                                 autocomplete: "email", required: true %>
          </div>

          <div class="mb-3">
            <%= form.label :password, class: "form-label" %>
            <%= form.password_field :password, class: "form-control",
                                    autocomplete: "new-password", required: true %>
            <div class="form-text">Use at least 8 characters.</div>
          </div>

          <div class="mb-4">
            <%= form.label :password_confirmation, class: "form-label" %>
            <%= form.password_field :password_confirmation,
                                    class: "form-control",
                                    autocomplete: "new-password", required: true %>
          </div>

          <%= form.submit "Register", class: "btn btn-primary w-100" %>
        <% end %>

        <p class="text-center mt-3 mb-0">
          Already registered? <%= link_to "Log in", login_path %>
        </p>
      </div>
    </div>
  </div>
</div>

Step 12: Build the login page

<!-- app/views/sessions/new.html.erb -->
<div class="row justify-content-center">
  <div class="col-md-5">
    <div class="card shadow-sm border-0">
      <div class="card-body p-4">
        <h1 class="h3 mb-4">Login</h1>

        <%= form_with url: login_path do |form| %>
          <div class="mb-3">
            <%= form.label :email, class: "form-label" %>
            <%= form.email_field :email, value: params[:email],
                                 class: "form-control",
                                 autocomplete: "email", required: true %>
          </div>

          <div class="mb-4">
            <%= form.label :password, class: "form-label" %>
            <%= form.password_field :password, class: "form-control",
                                    autocomplete: "current-password", required: true %>
          </div>

          <%= form.submit "Login", class: "btn btn-primary w-100" %>
        <% end %>

        <p class="text-center mt-3 mb-0">
          Need an account? <%= link_to "Register", register_path %>
        </p>
      </div>
    </div>
  </div>
</div>

Step 13: Build the My Account page

<!-- app/views/accounts/show.html.erb -->
<div class="row justify-content-center">
  <div class="col-lg-7">
    <div class="card shadow-sm border-0">
      <div class="card-header bg-primary text-white">
        <h1 class="h4 mb-0">My Account</h1>
      </div>
      <div class="card-body p-4">
        <dl class="row mb-4">
          <dt class="col-sm-4">Name</dt>
          <dd class="col-sm-8"><%= @user.full_name %></dd>
          <dt class="col-sm-4">Email</dt>
          <dd class="col-sm-8"><%= @user.email %></dd>
          <dt class="col-sm-4">Member since</dt>
          <dd class="col-sm-8"><%= @user.created_at.strftime("%B %d, %Y") %></dd>
        </dl>

        <%= link_to "Edit Account", edit_account_path, class: "btn btn-primary" %>
        <%= button_to "Logout", logout_path, method: :delete,
                      class: "btn btn-outline-danger ms-2 d-inline-block" %>
      </div>
    </div>
  </div>
</div>

Step 14: Build the account edit page

<!-- app/views/accounts/edit.html.erb -->
<div class="row justify-content-center">
  <div class="col-lg-6">
    <div class="card shadow-sm border-0">
      <div class="card-body p-4">
        <h1 class="h3 mb-4">Edit My Account</h1>
        <%= render "shared/errors", object: @user %>

        <%= form_with model: @user, url: account_path, method: :patch do |form| %>
          <div class="mb-3">
            <%= form.label :first_name, class: "form-label" %>
            <%= form.text_field :first_name, class: "form-control", required: true %>
          </div>

          <div class="mb-3">
            <%= form.label :last_name, class: "form-label" %>
            <%= form.text_field :last_name, class: "form-control", required: true %>
          </div>

          <div class="mb-3">
            <%= form.label :email, class: "form-label" %>
            <%= form.email_field :email, class: "form-control", required: true %>
          </div>

          <hr>
          <p class="text-muted">Leave both password fields blank to keep your current password.</p>

          <div class="mb-3">
            <%= form.label :password, "New password", class: "form-label" %>
            <%= form.password_field :password, class: "form-control",
                                    autocomplete: "new-password" %>
          </div>

          <div class="mb-4">
            <%= form.label :password_confirmation, class: "form-label" %>
            <%= form.password_field :password_confirmation,
                                    class: "form-control",
                                    autocomplete: "new-password" %>
          </div>

          <%= form.submit "Update Account", class: "btn btn-primary" %>
          <%= link_to "Cancel", account_path, class: "btn btn-outline-secondary" %>
        <% end %>
      </div>
    </div>
  </div>
</div>

Step 15: Add request tests

# test/controllers/authentication_flow_test.rb
require "test_helper"

class AuthenticationFlowTest < ActionDispatch::IntegrationTest
  setup do
    @user = User.create!(
      first_name: "Ruby",
      last_name: "Learner",
      email: "ruby@example.com",
      password: "password123",
      password_confirmation: "password123"
    )
  end

  test "guest is redirected from account" do
    get account_path
    assert_redirected_to login_path
  end

  test "user can register" do
    assert_difference("User.count", 1) do
      post register_path, params: {
        user: {
          first_name: "New",
          last_name: "User",
          email: "new@example.com",
          password: "password123",
          password_confirmation: "password123"
        }
      }
    end

    assert_redirected_to account_path
  end

  test "user can log in and view account" do
    post login_path, params: {
      email: @user.email,
      password: "password123"
    }

    assert_redirected_to account_path
    follow_redirect!
    assert_response :success
    assert_includes response.body, "Ruby Learner"
  end

  test "invalid password does not log in" do
    post login_path, params: {
      email: @user.email,
      password: "wrong-password"
    }

    assert_response :unprocessable_entity
    assert_includes response.body, "Invalid email or password"
  end

  test "user can log out" do
    post login_path, params: {
      email: @user.email,
      password: "password123"
    }

    delete logout_path
    assert_redirected_to login_path

    get account_path
    assert_redirected_to login_path
  end
end
bin/rails test

Step 16: Verify the application manually

  1. Start Rails with bin/rails server.
  2. Open http://localhost:3000/register and create an account.
  3. Confirm you land on /account.
  4. Edit the name or email without entering a password.
  5. Log out and confirm /account redirects to /login.
  6. Try an incorrect password and confirm the response does not reveal whether an email exists.

Security checklist

  • Passwords are hashed by BCrypt and never stored or logged as plain text.
  • Authentication errors use one generic message.
  • The session is reset after registration, login, and logout.
  • Account pages derive the user from session[:user_id], not a URL parameter.
  • Email uniqueness exists in both Rails validation and the database index.
  • Rails CSRF protection remains enabled for all forms.
  • Production should use HTTPS, secure cookies, rate limiting, and email verification.

Common problems

BCrypt::Errors::InvalidHash

Confirm the users table contains a password_digest column and that BCrypt is installed. Existing plain-text password data cannot be used as a BCrypt digest.

Logout link sends a GET request

Use button_to with method: :delete, or verify that Turbo is loaded correctly if you use a link with a data method.

Profile update asks for a password every time

Remove blank password fields before calling update, as shown in AccountsController.

Conclusion

You now have a complete Rails authentication foundation with registration, login, protected account pages, profile editing, and logout. The implementation stays small enough to understand while following important security practices. For larger applications, consider adding password reset emails, email confirmation, account locking, multi-factor authentication, and a mature authentication library such as Devise.

Leave a Reply

Your email address will not be published. Required fields are marked *