AI Reading
Quick summary of this article
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
- Start Rails with
bin/rails server. - Open
http://localhost:3000/registerand create an account. - Confirm you land on
/account. - Edit the name or email without entering a password.
- Log out and confirm
/accountredirects to/login. - 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.
