Ruby on Rails Zero to Advanced #9: Authentication, Sessions & Testing
Our blog from part 8 lets anyone create, edit and delete articles, and every article belongs to User.first. Time to fix that. In this part we add authentication with has_secure_password, remember logged-in users with the session, protect pages with before_action, and write our first tests so we can change code without fear.
Rails 8 ships a generator (bin/rails generate authentication) that builds a complete login system. It is great for real projects, but building a small version by hand first shows you exactly how the pieces fit together.
Storing passwords with has_secure_password
Never store plain-text passwords. has_secure_password hashes them with bcrypt and stores only the hash in a password_digest column. Enable the gem (it is commented out in new apps) and add the column:
bundle add bcrypt
bin/rails generate migration AddPasswordDigestToUsers password_digest:string
bin/rails db:migrate
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
has_many :articles, dependent: :destroy
normalizes :email, with: ->(email) { email.strip.downcase }
validates :name, presence: true
validates :email, presence: true, uniqueness: true
validates :password, length: { minimum: 8 }, allow_nil: true
end
has_secure_password adds virtual password and password_confirmation attributes, presence and confirmation validations, and an authenticate method. Try it in the console (the password here is only a demo value):
siti = User.create!(name: "Siti", email: "siti@example.com",
password: "example-password", password_confirmation: "example-password")
siti.password_digest
siti.authenticate("wrong-guess")
siti.authenticate("example-password").name
User.authenticate_by(email: "SITI@example.com", password: "example-password").name
Output (SQL omitted):
=> #<User:0x000000013a0c5e28 id: 2, name: "Siti", email: "siti@example.com", password_digest: "[FILTERED]", ...>
=> "$2a$12$q5mJ8k2Yd3c0pVn7Q1eZ6uY7l9u0yJb8fF0h2kQ1x3w4c5v6b7n8m"
=> false
=> "Siti"
=> "Siti"
The digest is a one-way bcrypt hash (yours will differ every time, because of the random salt). authenticate_by (Rails 7.1+) looks up the user and checks the password in a way that takes the same time whether or not the email exists, which prevents timing attacks. Note how normalizes made the uppercase email match.
Logging in with sessions
HTTP is stateless, so after a successful login we store the user's id in the session. Rails keeps the session in a cookie that is encrypted and signed with secret_key_base from Rails.application.credentials, so users can read neither nor tamper with it. Routes first:
# config/routes.rb
Rails.application.routes.draw do
root "articles#index"
resource :session, only: [:new, :create, :destroy]
resources :articles
end
resource (singular) creates routes without an :id: GET /session/new, POST /session and DELETE /session, because a visitor only ever has one session.
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
skip_before_action :require_login, only: [:new, :create]
def new
end
def create
user = User.authenticate_by(email: params[:email], password: params[:password])
if user
reset_session # prevents session fixation
session[:user_id] = user.id
redirect_to root_path, notice: "Welcome back, #{user.name}!"
else
flash.now[:alert] = "Invalid email or password."
render :new, status: :unprocessable_entity
end
end
def destroy
reset_session
redirect_to new_session_path, notice: "You have been logged out.", status: :see_other
end
end
<!-- app/views/sessions/new.html.erb -->
<h1>Log in</h1>
<p><%= flash[:alert] %></p>
<%= form_with url: session_path do |form| %>
<%= form.email_field :email, required: true, placeholder: "you@example.com" %>
<%= form.password_field :password, required: true %>
<%= form.submit "Log in" %>
<% end %>
Submitting the form produces this log. Rails filters the password automatically (see config/initializers/filter_parameter_logging.rb):
Output:
Started POST "/session" for 127.0.0.1 at 2026-10-13 15:30:02 +0700
Processing by SessionsController#create as TURBO_STREAM
Parameters: {"authenticity_token" => "[FILTERED]", "email" => "siti@example.com", "password" => "[FILTERED]", "commit" => "Log in"}
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."email" = ? LIMIT ? [["email", "siti@example.com"], ["LIMIT", 1]]
Redirected to http://localhost:3000/
Completed 302 Found in 241ms
The 241 ms is mostly bcrypt. Being slow on purpose is what makes password hashes hard to brute-force.
current_user and before_action guards
Put the shared logic in ApplicationController so every controller inherits it. helper_method also makes the methods available in views.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :require_login
helper_method :current_user, :logged_in?
private
def current_user
return unless session[:user_id]
@current_user ||= User.find_by(id: session[:user_id])
end
def logged_in?
current_user.present?
end
def require_login
return if logged_in?
redirect_to new_session_path, alert: "Please log in first."
end
end
Now the controller from part 8 can open public pages and scope every change to the current user:
class ArticlesController < ApplicationController
skip_before_action :require_login, only: [:index, :show]
before_action :set_own_article, only: [:edit, :update, :destroy]
def create
@article = current_user.articles.new(article_params)
# ... same as part 8
end
private
def set_own_article
@article = current_user.articles.find(params[:id])
end
end
Using current_user.articles.find means Budi cannot edit Siti's article even if he types its URL: the query includes WHERE user_id = ? and raises RecordNotFound (404). Check the guard as a guest:
curl -I http://localhost:3000/articles/new
Output:
HTTP/1.1 302 Found
location: http://localhost:3000/session/new
content-type: text/html; charset=utf-8
...
In the layout, show different links depending on the state:
<% if logged_in? %>
Hi, <%= current_user.name %>
<%= button_to "Log out", session_path, method: :delete %>
<% else %>
<%= link_to "Log in", new_session_path %>
<% end %>
Output (logged in as Siti, simplified):
Hi, Siti
<form class="button_to" method="post" action="/session"><input type="hidden" name="_method" value="delete" autocomplete="off" /><button type="submit">Log out</button>...</form>
Testing basics
Rails comes with Minitest and a separate test database. Fixtures in test/fixtures provide sample records; ERB works inside them, which lets us create a password digest:
# test/fixtures/users.yml
budi:
name: Budi
email: budi@example.com
password_digest: <%= BCrypt::Password.create("example-password") %>
A model test checks business rules in isolation:
# test/models/user_test.rb
require "test_helper"
class UserTest < ActiveSupport::TestCase
test "normalizes email" do
user = User.new(email: " Budi@Example.COM ")
assert_equal "budi@example.com", user.email
end
test "is invalid without email" do
user = User.new(name: "Budi", password: "example-password")
assert_not user.valid?
assert_includes user.errors[:email], "can't be blank"
end
end
An integration test sends real requests through routes, controllers and views:
# test/integration/articles_flow_test.rb
require "test_helper"
class ArticlesFlowTest < ActionDispatch::IntegrationTest
test "guests are redirected to login" do
get new_article_path
assert_redirected_to new_session_path
end
test "logged-in user can create an article" do
post session_path, params: { email: "budi@example.com", password: "example-password" }
assert_difference("Article.count", 1) do
post articles_path, params: { article: { title: "Testing in Rails", body: "Fun" } }
end
assert_redirected_to article_path(Article.last)
end
end
bin/rails test
Output:
Running 4 tests in a single process (parallelization threshold is 50)
Run options: --seed 31415
# Running:
....
Finished in 0.412345s, 9.7006 runs/s, 14.5509 assertions/s.
4 runs, 6 assertions, 0 failures, 0 errors, 0 skips
Each dot is a passing test. Remove the normalizes line from the model and run again to see what a failure looks like:
Output:
F
Failure:
UserTest#test_normalizes_email [test/models/user_test.rb:6]:
Expected: "budi@example.com"
Actual: " Budi@Example.COM "
bin/rails test test/models/user_test.rb:4
The last line is a ready-to-paste command that re-runs only the failing test.
Common mistakes
- Forgetting reset_session on login. Without it, an attacker who planted a session id before login could reuse it (session fixation).
- Different messages for "wrong email" and "wrong password". That tells attackers which emails are registered. Use one generic message.
- Finding records by id only.
Article.find(params[:id])ineditlets anyone edit anything. Scope throughcurrent_user. - Committing secrets. Never commit
config/master.key. In production, provide it via theRAILS_MASTER_KEYenvironment variable. - Stale fixtures. Generated fixtures may miss required columns you added later (like
user_id), causing NOT NULL errors before any test runs. Keep them in sync.
Exercise
Add a sign-up page: resources :users, only: [:new, :create], a UsersController that skips require_login, permits params.expect(user: [:name, :email, :password, :password_confirmation]), and logs the user in after saving. Then add an integration test for a failed login:
test "wrong password shows an error" do
post session_path, params: { email: "budi@example.com", password: "nope" }
assert_response :unprocessable_entity
assert_match "Invalid email or password.", response.body
end
Output:
5 runs, 8 assertions, 0 failures, 0 errors, 0 skips
Summary
has_secure_password hashes passwords with bcrypt, the encrypted session cookie remembers who is logged in, before_action :require_login plus current_user scoping protects pages and data, and Minitest with fixtures, model tests and integration tests gives you a safety net. In the final part 10 we turn the app into a JSON API, fix N+1 queries, move slow work to background jobs, add caching and walk through a deployment checklist.
Related articles
PostgreSQL Zero to Advanced #8: Indexes & Performance
Read query plans with EXPLAIN ANALYZE and speed up a 500,000-row table with B-tree, composite, partial and GIN indexes.
PHP Zero to Advanced #8: Composer, Namespaces, PSR-4 Autoloading & Exceptions
Structure real PHP projects: organize code with namespaces, manage dependencies with Composer, autoload classes with PSR-4, and handle errors cleanly with exceptions.
Python Zero to Advanced #8: OOP - Classes, Inheritance, Dunder Methods & Dataclasses
Model real-world things with classes: attributes, methods, class vs instance data, properties, inheritance and super(), dunder methods like __repr__ and __eq__, and modern dataclasses.