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," Sara@Example.COM "becomes"sara@example.com", 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: " Me@Example.com ", password: "secret123", password_confirmation: "secret123") user.email_address # => "me@example.com" user.password_digest # => "$2a$12$..." (a hash, not the password) User.authenticate_by(email_address: "me@example.com", password: "secret123") # => the user User.authenticate_by(email_address: "me@example.com", 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:
<h1 class="font-bold text-2xl">Sign up</h1> <%= 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 %> <h1 class="font-bold text-4xl">Dashboard</h1> <%= 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: " Me@Example.COM ", password: "secret123") assert_equal "me@example.com", user.email_address end test "requires a valid, unique email address" do User.create!(email_address: "me@example.com", password: "secret123") assert_not User.new(email_address: "not-an-email", password: "secret123").valid? assert_not User.new(email_address: "ME@example.com", password: "secret123").valid? end test "authenticate_by accepts the right password only" do user = User.create!(email_address: "me@example.com", password: "secret123") assert_equal user, User.authenticate_by(email_address: "me@example.com", password: "secret123") assert_nil User.authenticate_by(email_address: "me@example.com", 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: "me@example.com", 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: "me@example.com", password: "secret123" } assert_redirected_to root_url follow_redirect! assert_response :success end test "rejects invalid credentials" do post session_path, params: { email_address: "me@example.com", 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: "me@example.com", 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: "new@example.com", password: "password", password_confirmation: "password" } } end assert_redirected_to root_path assert_equal 1, User.find_by!(email_address: "new@example.com").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.


