Authentication with the Rails 8 generator

Ruby on Rails · Practical · 15 min

Rails 8 ships its own authentication, so you can understand and own every line instead of relying on a large gem.

Rails 8 includes an authentication generator: bin/rails generate authentication. It creates:

  • A User model with has_secure_password and an email_address column, plus a Session model that stores each login (with IP and user agent) in the database.
  • An Authentication concern included in ApplicationController, which requires login for every action by default. Opt out per controller with allow_unauthenticated_access only: %i[index show].
  • SessionsController for sign-in and sign-out, rate-limited with rate_limit to: 10, within: 3.minutes, only: :create, and PasswordsController with a mailer for password resets.
  • Current.user and Current.session, available anywhere during a request through Current (an ActiveSupport::CurrentAttributes class).

There is deliberately no sign-up flow; you add a registration controller that fits your app.

has_secure_password (needs the bcrypt gem) adds password and password_confirmation virtual attributes, stores a bcrypt password_digest, and provides authenticate. The generator uses User.authenticate_by(email_address:, password:), which takes constant time whether or not the user exists, so attackers can't detect valid emails by timing.

The session id lives in a signed, httponly permanent cookie. Signing out destroys the Session row, which makes revoking all of a user's sessions easy.

For OAuth, add OmniAuth; Devise remains an option for apps that need many features out of the box. Authorization (who may do what) is separate: check ownership in controllers, e.g. Current.user.articles.find(params[:id]), or use a gem like Pundit.

Example

# $ bin/rails generate authentication && bin/rails db:migrate
# app/models/user.rb (generated)
class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy
  normalizes :email_address, with: ->(e) { e.strip.downcase }
end
# app/controllers/sessions_controller.rb (generated, abridged)
class SessionsController < ApplicationController
  allow_unauthenticated_access only: %i[new create]
  rate_limit to: 10, within: 3.minutes, only: :create,
             with: -> { redirect_to new_session_url, alert: "Try again later." }
  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
end
# Your controllers: scope records to the signed-in user (authorization)
class ArticlesController < ApplicationController
  def edit = @article = Current.user.articles.find(params[:id])   # 404 for others' articles
end

Remember

  • bin/rails generate authentication creates User, Session, controllers and the Authentication concern
  • Every action requires login unless allow_unauthenticated_access is used
  • authenticate_by and rate_limit defend against timing and brute-force attacks

Watch out for

  • Treating authentication as authorization: a logged-in user can still load other users' records unless you scope queries.

Check yourself

  1. Why does the generator use User.authenticate_by instead of find_by(...)&.authenticate(...)?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: guides.rubyonrails.org/security.html