Learn Rails 8 by Building Study Flow, Part 2: Authentication with the Built-in Generator
In Part 1 we generated a Rails 8 app and followed a request from route to controller. Today Study Flow gets its first real feature: users. Before this Rails version, most people added a gem like Devise to get login. Rails 8 ships its own authentication generator. It writes plain Ruby code into your app, so you can read all of it, and that's exactly what we'll do. What you'll learn Running the Rails 8 authentication generator What has_secure_password does for you How a login works: the Session model, signed cookies and Current How allow_unauthenticated_access and rate_limit protect your controllers Adding a signup page, which the generator doesn't create, using params.expect The idea in one minute A website needs to remember who you are between requests, because HTTP forgets everything after each one. The usual trick is a cookie: You log in with email and password. The app creates a session record in the database and puts its id in a cookie. On every later
In Part 1 we generated a Rails 8 app and followed a request from route to controller. Today Study Flow gets its first real feature: users.
Before this Rails version, most people added a gem like Devise to get login. Rails 8 ships its own authentication generator. It writes plain Ruby code into your app, so you can read all of it, and that's exactly what we'll do.
What you'll learn
- Running the Rails 8 authentication generator
- What
has_secure_passworddoes for you - How a login works: the
Sessionmodel, signed cookies andCurrent - How
allow_unauthenticated_accessandrate_limitprotect your controllers - Adding a signup page, which the generator doesn't create, using
params.expect
The idea in one minute
A website needs to remember who you are between requests, because HTTP forgets everything after each one. The usual trick is a cookie:
- You log in with email and password.
- The app creates a session record in the database and puts its id in a cookie.
- On every later request, the browser sends the cookie back, and the app looks up the session to find the user.
Think of a coat check. You hand over your coat (password) once, get a numbered ticket (cookie), and every time you show the ticket, they know which coat is yours.
Step 1: Run the generator
bin/rails generate authentication
bin/rails db:migrate
Make sure bcrypt is enabled in your Gemfile (the generator uncomments it, then run bundle install):
gem "bcrypt", "~> 3.1.7"
The generator created a lot. Here's what matters:
app/models/user.rb
app/models/session.rb
app/models/current.rb
app/controllers/concerns/authentication.rb
app/controllers/sessions_controller.rb
app/controllers/passwords_controller.rb
app/mailers/passwords_mailer.rb
app/views/sessions/new.html.erb
app/views/passwords/...
db/migrate/..._create_users.rb
db/migrate/..._create_sessions.rb
Let's go through them.
Step 2: The two tables
# db/migrate/..._create_users.rb
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email_address, unique: true
# db/migrate/..._create_sessions.rb
create_table :sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :ip_address
t.string :user_agent
t.timestamps
end
Two details are worth noticing:
- Users store a
password_digest, never the password itself. It's a one-way hash. - A
Sessionis a database row, one per login, recording the IP and browser. Don't confuse it with Rails' built-insessionhash. If you log in on your laptop and your phone, you get two rows, and you can end one without the other.
Step 3: The models
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
validates :email_address, presence: true, uniqueness: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
normalizes :email_address, with: ->(e) { e.strip.downcase }
end
class Session < ApplicationRecord
belongs_to :user
end
Three lines of the User model do a lot:
-
has_secure_passwordaddspasswordandpassword_confirmationattributes, hashes the password with bcrypt intopassword_digest, and gives youauthenticate_byand a password reset token. -
normalizescleans a value before saving or querying. With this," [email protected] "becomes"[email protected]", so users can't sign up twice with different capitalization. -
dependent: :destroydeletes a user's sessions when the user is deleted.
You can try it in the console:
bin/rails console
user = User.create!(email_address: " [email protected] ", password: "secret123", password_confirmation: "secret123")
user.email_address
# => "[email protected]"
user.password_digest
# => "$2a$12$..." (a hash, not the password)
User.authenticate_by(email_address: "[email protected]", password: "secret123")
# => the user
User.authenticate_by(email_address: "[email protected]", password: "wrong")
# => nil
Step 4: Current: a request-wide global, done safely
class Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
end
Controllers, views and models often need to know who's logged in. Current stores the current session for the duration of one request and then resets automatically. That gives you Current.session and Current.user anywhere in your app without passing them around.
It's safe because it's cleared between requests. A normal global variable would leak between users.
Step 5: The Authentication concern
This module is the heart of the system:
module Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :authenticated?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
end
private
def authenticated?
resume_session
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]) if cookies.signed[:session_id]
end
def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_path
end
def after_authentication_url
session.delete(:return_to_after_authenticating) || root_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
Read it as a story:
-
included do ... endruns in the controller that includes the module. Addingbefore_action :require_authenticationmeans every controller is locked by default. That's a safe default: you must opt out, not remember to opt in. -
require_authenticationtries to find the session from the cookie. If there isn't one, it remembers the page you wanted (return_to_after_authenticating) and sends you to the login page. -
cookies.signedmeans the cookie can't be tampered with.httponly: truehides it from JavaScript.same_site: :laxblocks it on most cross-site requests. -
start_new_session_foris called after a successful login. It creates theSessionrow and sets the cookie. -
after_authentication_urlsends you back to the page you originally wanted, or to the root page.
ApplicationController includes the concern:
class ApplicationController < ActionController::Base
include Authentication
# ...
end
Step 6: The login controller
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ 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(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
end
end
def destroy
terminate_session
redirect_to new_session_path, status: :see_other
end
end
-
allow_unauthenticated_access only: %i[new create]opens the login form and the login action to logged-out visitors. Everything else stays locked. -
rate_limitis new in Rails 8. It allows 10 login attempts every 3 minutes per IP address, which slows down password guessing. No extra gem needed. -
User.authenticate_byfinds the user and checks the password in a way that takes the same time whether or not the email exists. That prevents attackers from discovering which emails are registered by measuring response times. - The error message is the same for a wrong email and a wrong password, on purpose.
-
status: :see_other(HTTP 303) afterdestroymakes the browser follow the redirect with a GET. This matters with Turbo, which we'll meet in Part 5.
Step 7: Password reset (already built)
The generator also gave you PasswordsController and PasswordsMailer. The flow:
- The user enters their email on
/passwords/new. - If the email exists, the app emails a link containing a signed, expiring token (
user.password_reset_token, supplied byhas_secure_password). - The link opens
/passwords/:token/edit, where the user picks a new password. - On success, all the user's sessions are destroyed, which logs them out everywhere.
Notice the response in create is the same whether or not the email exists. Again, that avoids leaking who has an account.
We'll look at how mailers work in Part 7. For now just know it works.
Step 8: Signup, which the generator doesn't give you
The generator creates login but no registration page. Let's add one.
Routes (config/routes.rb):
Rails.application.routes.draw do
root "dashboard#show"
resource :session
resources :passwords, param: :token
resource :registration, only: %i[new create]
get "up" => "rails/health#show", as: :rails_health_check
end
resource :registration (singular) creates /registration/new and POST /registration. There's no id, because a signed-in user has just one of these at a time. We'll compare singular and plural resources in Part 3.
Controller:
class RegistrationsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create,
with: -> { redirect_to new_registration_path, alert: "Try again later." }
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save
start_new_session_for @user
redirect_to after_authentication_url, notice: "Welcome! Your account has been created."
else
flash[:alert] = @user.errors.full_messages.join(", ")
render :new, status: :unprocessable_content
end
end
private
def user_params
params.expect(user: [ :email_address, :password, :password_confirmation ])
end
end
Three things to learn here:
-
Signing up logs you in right away. We reuse
start_new_session_for, the same method login uses. -
params.expectis new in Rails 8. Old Rails usedparams.require(:user).permit(...). Withexpect, if theuserkey is missing or has the wrong shape, Rails answers with a clean400 Bad Requestinstead of crashing with an error. It also lists which fields you allow (this is called strong parameters, and it stops users from sending fields likeadmin: true). -
render :new, status: :unprocessable_content. When validation fails we show the form again, with a 422 status. Turbo needs this status to know the form failed, and it won't re-render the page otherwise.
View (app/views/registrations/new.html.erb). This is trimmed down, and the repo has the full styled version:
class="font-bold text-2xl">Sign up
<%= form_with model: @user, url: registration_url do |form| %>
<%= form.email_field :email_address, required: true, autofocus: true,
autocomplete: "username", placeholder: "Enter your email address" %>
<%= form.password_field :password, required: true,
autocomplete: "new-password", maxlength: 72, placeholder: "Enter your password" %>
<%= form.password_field :password_confirmation, required: true,
autocomplete: "new-password", maxlength: 72, placeholder: "Password confirmation" %>
<%= form.submit "Sign up" %>
<% end %>
<%= link_to "Sign In", new_session_path %>
maxlength: 72 is there because bcrypt only reads the first 72 bytes of a password.
Step 9: A page to land on
A user who signs up needs somewhere to go, so we add a small dashboard.
bin/rails generate controller dashboard show
class DashboardController < ApplicationController
def show
end
end
<%# app/views/dashboard/show.html.erb %>
class="font-bold text-4xl">Dashboard
<%= button_to "Log out", session_path, method: :delete %>
We use button_to for logout instead of a plain link. A link sends a GET request, but logging out must be a DELETE, and button_to makes a tiny form that sends the right method.
Because ApplicationController is locked by default and DashboardController doesn't call allow_unauthenticated_access, visitors who aren't logged in will be redirected to the sign-in page. Try it:
- Run
bin/dev. - Visit
http://localhost:3000. You're sent to the login page. - Click Sign Up, create an account, and you land on the dashboard.
- Click Log out, and you're back at the login page.
✅ How to test this
Manual checks, as above, plus one extra: after logging in, open your browser's dev tools, then Application → Cookies. You'll see session_id, and its value is unreadable because it's signed.
Now automated tests. Create test/models/user_test.rb:
require "test_helper"
class UserTest < ActiveSupport::TestCase
test "normalizes the email address" do
user = User.create!(email_address: " [email protected] ", password: "secret123")
assert_equal "[email protected]", user.email_address
end
test "requires a valid, unique email address" do
User.create!(email_address: "[email protected]", password: "secret123")
assert_not User.new(email_address: "not-an-email", password: "secret123").valid?
assert_not User.new(email_address: "[email protected]", password: "secret123").valid?
end
test "authenticate_by accepts the right password only" do
user = User.create!(email_address: "[email protected]", password: "secret123")
assert_equal user, User.authenticate_by(email_address: "[email protected]", password: "secret123")
assert_nil User.authenticate_by(email_address: "[email protected]", password: "wrong")
end
end
Then test/controllers/sessions_controller_test.rb:
require "test_helper"
class SessionsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = User.create!(email_address: "[email protected]", password: "secret123")
end
test "redirects visitors to the login page" do
get root_path
assert_redirected_to new_session_path
end
test "signs in with valid credentials" do
post session_path, params: { email_address: "[email protected]", password: "secret123" }
assert_redirected_to root_url
follow_redirect!
assert_response :success
end
test "rejects invalid credentials" do
post session_path, params: { email_address: "[email protected]", password: "wrong" }
assert_redirected_to new_session_path
assert_equal "Try another email address or password.", flash[:alert]
end
test "signs out and removes the session record" do
post session_path, params: { email_address: "[email protected]", password: "secret123" }
assert_difference -> { @user.sessions.count }, -1 do
delete session_path
end
assert_redirected_to new_session_path
end
end
And test/controllers/registrations_controller_test.rb:
require "test_helper"
class RegistrationsControllerTest < ActionDispatch::IntegrationTest
test "creates an account and signs the user in" do
assert_difference("User.count", 1) do
post registration_path, params: {
user: { email_address: "[email protected]", password: "password", password_confirmation: "password" }
}
end
assert_redirected_to root_path
assert_equal 1, User.find_by!(email_address: "[email protected]").sessions.count
end
test "re-renders the form for invalid input" do
assert_no_difference("User.count") do
post registration_path, params: {
user: { email_address: "not-an-email", password: "password" }
}
end
assert_response :unprocessable_content
end
end
Run them:
bin/rails test
You'll see them all pass. We don't test rate_limit here because the test environment uses a null cache store, so the counter never accumulates. In Part 12 we'll talk about caches and revisit it.
Common mistakes
-
Logout does nothing or raises a routing error. You used
link_to ..., method: :delete. Turbo ignores that option. Usebutton_to, orlink_towithdata: { turbo_method: :delete }. -
bcrypterror on user creation. The gem is still commented out in the Gemfile. Enable it and runbundle install. -
Redirect loops on the login page. A controller that shows the login form must call
allow_unauthenticated_access, otherwise it asks for a login to show the login.
Recap
-
bin/rails generate authenticationgives you users, sessions, login, logout and password reset, as plain readable code. -
has_secure_passwordhandles hashing;normalizescleans emails;authenticate_bychecks credentials safely. - A login creates a
Sessionrow and a signed cookie;Currentexposes the user for one request. - Controllers are locked by default;
allow_unauthenticated_accessopens specific actions;rate_limitguards against guessing. -
params.expectis the modern, safer strong parameters.
Next up
In Part 3 we'll build our first real CRUD: study plans. You'll learn resourceful routes, migrations, associations and validations, and how to make sure users only see their own data.