
Authentication Flow
- 2 installs
- Updated February 9, 2026
- dchuk/rails_ai_agents
Sets up user authentication in Rails 8 using the built-in generator, covering login/logout, sessions, and password resets without external gems.
About
Implements a complete authentication system using the Rails 8 built-in generator. A developer uses it when adding login/logout, session management, password reset flows, or securing controllers.
- Uses Rails 8 built-in auth generator, no external gems
- Covers sessions, password reset, and controller protection
Authentication Flow by the numbers
- 2 all-time installs (skills.sh)
- Ranked #3,765 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dchuk/rails_ai_agents --skill authentication-flowAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| Last updated | February 9, 2026 |
| Repository | dchuk/rails_ai_agents ↗ |
What it does
Sets up user authentication in Rails 8 using the built-in generator, covering login/logout, sessions, and password resets without external gems.
Files
Rails 8 Authentication
Overview
Rails 8 includes a built-in authentication generator that creates a complete, secure authentication system without external gems.
Quick Start
# Generate authentication
bin/rails generate authentication
# Run migrations
bin/rails db:migrateThis creates:
Usermodel withhas_secure_passwordSessionmodel for secure sessionsCurrentmodel for request-local storage- Authentication concern for controllers
- Session and Password controllers
- Login/logout views
Generated Structure
app/
├── models/
│ ├── user.rb # User with has_secure_password
│ ├── session.rb # Session tracking
│ └── current.rb # Current.user accessor
├── controllers/
│ ├── sessions_controller.rb # Login/logout
│ ├── passwords_controller.rb # Password reset
│ └── concerns/
│ └── authentication.rb # Auth helpers
└── views/
├── sessions/
│ └── new.html.erb # Login form
└── passwords/
├── new.html.erb # Forgot password
└── edit.html.erb # Reset passwordCore Components
User Model
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
normalizes :email_address, with: -> { _1.strip.downcase }
validates :email_address, presence: true, uniqueness: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
endSession Model
# app/models/session.rb
class Session < ApplicationRecord
belongs_to :user
before_create { self.token = SecureRandom.urlsafe_base64(32) }
def self.find_by_token(token)
find_by(token: token) if token.present?
end
endCurrent Model
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
endAuthentication Concern
# app/controllers/concerns/authentication.rb
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?
Current.session.present?
end
def require_authentication
resume_session || request_authentication
end
def resume_session
if session_token = cookies.signed[:session_token]
if session = Session.find_by_token(session_token)
Current.session = session
end
end
end
def request_authentication
redirect_to new_session_path
end
def start_new_session_for(user)
session = user.sessions.create!
cookies.signed.permanent[:session_token] = { value: session.token, httponly: true }
Current.session = session
end
def terminate_session
Current.session&.destroy
cookies.delete(:session_token)
end
endUsage Patterns
Protecting Controllers
class ApplicationController < ActionController::Base
include Authentication
# All actions require authentication by default
end
class HomeController < ApplicationController
allow_unauthenticated_access only: [:index, :about]
endAccessing Current User
# In controllers and views
Current.user
Current.user.email_addressLogin Flow
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
allow_unauthenticated_access only: [:new, :create]
def new
end
def create
if user = User.authenticate_by(email_address: params[:email_address],
password: params[:password])
start_new_session_for(user)
redirect_to root_path, notice: "Signed in successfully"
else
flash.now[:alert] = "Invalid email or password"
render :new, status: :unprocessable_entity
end
end
def destroy
terminate_session
redirect_to root_path, notice: "Signed out"
end
endTesting Authentication
Test Helper
# test/test_helper.rb
class ActionDispatch::IntegrationTest
def sign_in(user)
session = user.sessions.create!
cookies[:session_token] = session.token
end
def sign_out
cookies.delete(:session_token)
end
endSession Controller Tests
# test/controllers/sessions_controller_test.rb
require "test_helper"
class SessionsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:one)
end
test "GET new renders login form" do
get new_session_path
assert_response :success
end
test "POST create with valid credentials signs in user" do
post session_path, params: {
email_address: @user.email_address,
password: "password123"
}
assert_redirected_to root_path
assert cookies[:session_token].present?
end
test "POST create with invalid credentials shows error" do
post session_path, params: {
email_address: @user.email_address,
password: "wrong"
}
assert_response :unprocessable_entity
end
test "DELETE destroy signs out user" do
sign_in @user
delete session_path
assert_redirected_to root_path
assert_nil cookies[:session_token]
end
endProtected Route Tests
# test/controllers/posts_controller_test.rb
require "test_helper"
class PostsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:one)
end
test "redirects to login when not authenticated" do
get posts_path
assert_redirected_to new_session_path
end
test "shows posts when authenticated" do
sign_in @user
get posts_path
assert_response :success
end
endReferences
- See sessions.md for session management details
- See current.md for Current attributes patterns
- See passwordless.md for magic link authentication
Common Customizations
Remember Me
def start_new_session_for(user, remember: false)
session = user.sessions.create!
cookie_options = { value: session.token, httponly: true }
cookie_options[:expires] = 2.weeks.from_now if remember
cookies.signed.permanent[:session_token] = cookie_options
Current.session = session
endMultiple Sessions Tracking
def active_sessions
sessions.where("created_at > ?", 30.days.ago)
end
def terminate_all_sessions_except(current_session)
sessions.where.not(id: current_session.id).destroy_all
endRate Limiting
# app/controllers/sessions_controller.rb
rate_limit to: 10, within: 3.minutes, only: :create,
with: -> { redirect_to new_session_path, alert: "Too many attempts" }Checklist
- [ ] Authentication generator run
- [ ] Test helper with
sign_in/sign_outmethods - [ ] Session controller tests written
- [ ] Protected route tests written
- [ ] Rate limiting on login
- [ ]
allow_unauthenticated_accesson public pages - [ ] All tests GREEN
Current Attributes Reference
Concept
Current uses ActiveSupport::CurrentAttributes to provide request-local storage, making request-specific data available throughout the application without passing it explicitly.
Basic Setup
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
# Attributes stored per-request
attribute :session
attribute :user_agent
attribute :ip_address
attribute :request_id
# Delegate to session for convenience
delegate :user, to: :session, allow_nil: true
# Resets automatically between requests
resets { Time.zone = nil }
endSetting Current Attributes
In ApplicationController
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :set_current_attributes
private
def set_current_attributes
Current.user_agent = request.user_agent
Current.ip_address = request.remote_ip
Current.request_id = request.request_id
end
endIn Authentication
# app/controllers/concerns/authentication.rb
def resume_session
if token = cookies.signed[:session_token]
if session = Session.find_by_token(token)
Current.session = session # Sets Current.session
end
end
endAccessing Current
In Controllers
class PostsController < ApplicationController
def create
@post = Current.user.posts.build(post_params)
# ...
end
def index
@posts = Current.user.posts
end
endIn Views
<% if Current.user %>
Logged in as: <%= Current.user.email_address %>
<%= link_to "Sign out", session_path, method: :delete %>
<% else %>
<%= link_to "Sign in", new_session_path %>
<% end %>In Models (Use Sparingly)
class Post < ApplicationRecord
belongs_to :user
before_create :set_author
private
def set_author
self.user ||= Current.user
end
endWarning: Using Current in models couples them to the request context. This makes testing harder and breaks in background jobs. Prefer passing the user explicitly.
In Mailers
class NotificationMailer < ApplicationMailer
def alert(user, message)
@user = user
@message = message
@request_id = Current.request_id # For logging correlation
mail(to: user.email)
end
endIn Jobs (Careful!)
class AuditLogJob < ApplicationJob
def perform(action:, user_id:, ip_address:, request_id:)
# Don't rely on Current - it's reset between requests
# Pass values explicitly
AuditLog.create!(
action: action,
user_id: user_id,
ip_address: ip_address,
request_id: request_id
)
end
end
# Enqueue with current values
AuditLogJob.perform_later(
action: "created_post",
user_id: Current.user.id,
ip_address: Current.ip_address,
request_id: Current.request_id
)Common Attributes
class Current < ActiveSupport::CurrentAttributes
# Authentication
attribute :session
delegate :user, to: :session, allow_nil: true
# Request metadata
attribute :request_id
attribute :user_agent
attribute :ip_address
# Timezone (per-user)
attribute :time_zone
# Feature flags
attribute :feature_flags
# Request tracking
attribute :request_start_time
endCallbacks
class Current < ActiveSupport::CurrentAttributes
attribute :session, :time_zone
# Called when session is set
after_reset do
Time.zone = nil
end
# Apply user's timezone when session is set
def session=(session)
super
self.time_zone = session&.user&.time_zone
Time.zone = time_zone if time_zone
end
endTesting
Stub Current in Tests
# test/support/current_helpers.rb
module CurrentHelpers
def with_current_user(user)
session = user.sessions.create!
Current.session = session
yield
ensure
Current.reset
end
end
In Tests
# test/models/post_test.rb
require "test_helper"
class PostTest < ActiveSupport::TestCase
test "sets author from Current.user" do
user = users(:one)
with_current_user(user) do
post = Post.create!(title: "Test")
assert_equal user, post.user
end
end
endController Tests
# test/controllers/posts_controller_test.rb
require "test_helper"
class PostsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:one)
sign_in_as @user # Sets Current.session
end
test "uses current user" do
post posts_path, params: { post: { title: "Test" } }
assert_equal @user, Post.last.user
end
endBest Practices
1. Controllers/Views: Safe to use Current.user freely 2. Models: Pass user explicitly when possible 3. Jobs: Never rely on Current - pass values explicitly 4. Mailers: Can use for metadata, but pass main data explicitly 5. Services: Accept user as parameter, don't assume Current 6. Tests: Reset Current between examples
Passwordless Authentication (Magic Links)
Alternative to password-based auth. Based on 37signals patterns.
Philosophy
Auth is simple. A basic system is ~150 lines of code total. You get full control, no bloat, and easier maintenance.
Core Models
Identity Model
# app/models/identity.rb
class Identity < ApplicationRecord
has_secure_password validations: false
has_many :sessions, dependent: :destroy
has_many :magic_links, dependent: :destroy
has_one :user, dependent: :destroy
validates :email_address, presence: true, uniqueness: { case_sensitive: false }
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }
normalizes :email_address, with: -> { _1.strip.downcase }
def send_magic_link(purpose: "sign_in")
magic_link = magic_links.create!(purpose: purpose)
MagicLinkMailer.sign_in_instructions(magic_link).deliver_later
magic_link
end
endMagic Link Model
# app/models/magic_link.rb
class MagicLink < ApplicationRecord
CODE_LENGTH = 6
belongs_to :identity
before_create :set_code
before_create :set_expiration
scope :unused, -> { where(used_at: nil) }
scope :active, -> { unused.where("expires_at > ?", Time.current) }
def self.authenticate(code)
active.find_by(code: code.upcase)&.tap do |magic_link|
magic_link.update!(used_at: Time.current)
end
end
def expired?
expires_at < Time.current
end
def used?
used_at.present?
end
def valid_for_use?
!expired? && !used?
end
private
def set_code
self.code = SecureRandom.alphanumeric(CODE_LENGTH).upcase
end
def set_expiration
self.expires_at = 15.minutes.from_now
end
endSession Model
# app/models/session.rb
class Session < ApplicationRecord
belongs_to :identity
has_secure_token length: 36
def active?
created_at > 30.days.ago
end
endControllers
Sessions Controller
class SessionsController < ApplicationController
allow_unauthenticated_access only: [:new, :create]
def new
end
def create
if identity = Identity.find_by(email_address: params[:email_address])
identity.send_magic_link
redirect_to new_session_path, notice: "Check your email for a sign-in link"
else
redirect_to new_session_path, alert: "No account found with that email"
end
end
def destroy
terminate_session
redirect_to root_path
end
endMagic Links Controller
class Sessions::MagicLinksController < ApplicationController
allow_unauthenticated_access
def show
if magic_link = MagicLink.authenticate(params[:code])
start_new_session_for(magic_link.identity)
redirect_to session.delete(:return_to) || root_path, notice: "Signed in successfully"
else
redirect_to new_session_path, alert: "Invalid or expired link"
end
end
endTesting
# test/models/identity_test.rb
class IdentityTest < ActiveSupport::TestCase
test "normalizes email address to lowercase" do
identity = Identity.create!(email_address: "TEST@EXAMPLE.COM")
assert_equal "test@example.com", identity.email_address
end
test "validates email format" do
identity = Identity.new(email_address: "invalid")
assert_not identity.valid?
assert_includes identity.errors[:email_address], "is invalid"
end
test "sends magic link" do
identity = identities(:david)
assert_difference -> { identity.magic_links.count }, 1 do
assert_enqueued_emails 1 do
identity.send_magic_link
end
end
end
end
# test/models/magic_link_test.rb
class MagicLinkTest < ActiveSupport::TestCase
test "generates 6-character code" do
magic_link = MagicLink.create!(identity: identities(:david))
assert_equal 6, magic_link.code.length
assert_match(/\A[A-Z0-9]+\z/, magic_link.code)
end
test "expires after 15 minutes" do
magic_link = MagicLink.create!(identity: identities(:david))
assert magic_link.valid_for_use?
travel 16.minutes do
assert magic_link.expired?
assert_not magic_link.valid_for_use?
end
end
test "authenticates with valid code" do
magic_link = MagicLink.create!(identity: identities(:david))
authenticated = MagicLink.authenticate(magic_link.code)
assert_equal magic_link, authenticated
assert authenticated.used?
end
test "does not authenticate used codes" do
magic_link = MagicLink.create!(identity: identities(:david))
MagicLink.authenticate(magic_link.code)
assert_nil MagicLink.authenticate(magic_link.code)
end
end
# test/controllers/sessions_controller_test.rb
class SessionsControllerTest < ActionDispatch::IntegrationTest
test "create sends magic link" do
identity = identities(:david)
assert_enqueued_emails 1 do
post session_path, params: { email_address: identity.email_address }
end
assert_redirected_to new_session_path
end
test "destroy terminates session" do
sign_in_as identities(:david)
delete session_path
assert_redirected_to root_path
assert_nil cookies[:session_token]
end
endTest Helper
# test/test_helper.rb
class ActionDispatch::IntegrationTest
def sign_in_as(identity)
session_record = identity.sessions.create!
cookies.signed[:session_token] = session_record.token
end
def sign_out
cookies.delete(:session_token)
end
endSecurity
- Use signed cookies with
httponly: trueandsame_site: :lax - Magic links expire in 15 minutes
- Magic links are one-time use
- Rate limit login attempts
- Clean up old sessions with a recurring job
# app/jobs/session_cleanup_job.rb
class SessionCleanupJob < ApplicationJob
def perform
Session.where("created_at < ?", 30.days.ago).delete_all
MagicLink.where("expires_at < ?", 1.day.ago).delete_all
end
endSession Management Reference
Session Model
# app/models/session.rb
class Session < ApplicationRecord
belongs_to :user
before_create :generate_token
before_create :set_metadata
scope :active, -> { where('created_at > ?', 30.days.ago) }
scope :expired, -> { where('created_at <= ?', 30.days.ago) }
def expired?
created_at <= 30.days.ago
end
private
def generate_token
self.token = SecureRandom.urlsafe_base64(32)
end
def set_metadata
self.ip_address = Current.ip_address
self.user_agent = Current.user_agent
end
endSession Table Schema
# db/migrate/xxx_create_sessions.rb
class CreateSessions < ActiveRecord::Migration[8.0]
def change
create_table :sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :token, null: false
t.string :ip_address
t.string :user_agent
t.timestamps
end
add_index :sessions, :token, unique: true
end
endCookie Security
Secure Cookie Settings
def start_new_session_for(user)
session = user.sessions.create!
cookies.signed.permanent[:session_token] = {
value: session.token,
httponly: true, # JavaScript can't access
secure: Rails.env.production?, # HTTPS only in production
same_site: :lax # CSRF protection
}
Current.session = session
endCookie Options
| Option | Purpose | Value |
|---|---|---|
httponly | Prevent XSS access | true |
secure | HTTPS only | true in production |
same_site | CSRF protection | :lax or :strict |
expires | Cookie lifetime | 2.weeks.from_now |
domain | Cookie scope | .example.com for subdomains |
Session Lifecycle
Starting Session
def start_new_session_for(user)
# Terminate existing sessions if desired
# user.sessions.destroy_all
session = user.sessions.create!
cookies.signed.permanent[:session_token] = {
value: session.token,
httponly: true
}
Current.session = session
endResuming Session
def resume_session
return unless (token = cookies.signed[:session_token])
return unless (session = Session.find_by_token(token))
return if session.expired?
# Update last seen
session.touch(:last_seen_at)
Current.session = session
endTerminating Session
def terminate_session
Current.session&.destroy
cookies.delete(:session_token)
reset_session # Clear Rails session too
endMultiple Device Sessions
Viewing Active Sessions
# app/controllers/sessions_controller.rb
def index
@sessions = Current.user.sessions.active.order(created_at: :desc)
@current_session = Current.session
end<%# app/views/sessions/index.html.erb %>
<h2>Active Sessions</h2>
<% @sessions.each do |session| %>
<div class="session <%= 'current' if session == @current_session %>">
<p><%= session.ip_address %></p>
<p><%= session.user_agent %></p>
<p>Started: <%= time_ago_in_words(session.created_at) %> ago</p>
<% unless session == @current_session %>
<%= button_to "Revoke", session_path(session), method: :delete %>
<% end %>
</div>
<% end %>
<%= button_to "Sign out all other devices",
revoke_all_sessions_path, method: :post %>Revoking Other Sessions
# app/controllers/sessions_controller.rb
def revoke_all
Current.user.sessions.where.not(id: Current.session.id).destroy_all
redirect_to sessions_path, notice: "All other sessions terminated"
endSession Cleanup
Scheduled Cleanup Job
# app/jobs/cleanup_expired_sessions_job.rb
class CleanupExpiredSessionsJob < ApplicationJob
queue_as :low
def perform
Session.expired.delete_all
end
end
# config/recurring.yml
cleanup_sessions:
class: CleanupExpiredSessionsJob
schedule: every day at 3amSecurity Considerations
1. Token Rotation: Regenerate token after password change 2. IP Binding: Optional - bind session to IP address 3. User Agent Tracking: Detect suspicious changes 4. Concurrent Session Limits: Limit active sessions per user 5. Session Timeout: Expire inactive sessions
# Rotate token on sensitive actions
def rotate_session_token
new_session = Current.user.sessions.create!
Current.session.destroy
cookies.signed.permanent[:session_token] = new_session.token
Current.session = new_session
end