Every Rails tutorial eventually gets to authentication, and most immediately reach for Devise or another gem. While these gems are great for production apps, building authentication from scratch taught me more about Rails than any other exercise.
In this post, I'll show you how to build user registration, login, and logout functionality from the ground up. You'll understand sessions, password hashing, and how authentication really works under the hood.
Why Build Authentication From Scratch?
Before we dive in, let me address the obvious question: "Why not just use Devise?"
For learning:
- You'll understand how authentication actually works
- You'll learn about sessions, cookies, and password security
- You'll gain confidence in building Rails features without gems
- You'll appreciate what gems like Devise do for you
For this tutorial:
- We'll build a simple system (email/password only)
- No email confirmation or password reset (yet)
- Focus on core concepts, not edge cases
Note: For production apps, do consider proven gems like Devise. Security is hard to get right.
Planning Our Authentication System
Our authentication will include:
1. User model with email and password
2. User registration (sign up)
3. User login (create session)
4. User logout (destroy session)
5. Protecting routes that require login
6. Current user helper methods
Step 1: Create the User Model
Let's start with a User model that can securely store passwords:
rails generate model User email:string password_digest:string
Important: We use password_digest
, not password
. This will store the hashed password, never the plain text.
The migration looks like this:
# db/migrate/xxx_create_users.rb
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :email
t.string :password_digest
t.timestamps
end
add_index :users, :email, unique: true
end
end
Run the migration:
rails db:migrate
Step 2: Add Password Security
Add this to your Gemfile if it's not already there:
# Gemfile
gem 'bcrypt', '~> 3.1.7'
Then run:
bundle install
Now update the User model:
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
validates :email, presence: true, uniqueness: true
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 6 }, if: -> { new_record? || !password.blank? }
end
What has_secure_password
does:
- Adds password
and password_confirmation
virtual attributes
- Automatically hashes passwords using bcrypt
- Adds an authenticate
method to verify passwords
- Adds presence validation for password (on create)
Let's test this in the console:
rails console
# Create a user
user = User.new(email: "[email protected]", password: "password123")
user.save
# The password is hashed
user.password_digest # Shows the hashed version, not "password123"
# Authenticate
user.authenticate("password123") # Returns the user object
user.authenticate("wrongpassword") # Returns false
Step 3: Create Registration (Sign Up)
Generate a controller for handling user registration:
rails generate controller Users new create show
Update the routes:
# config/routes.rb
Rails.application.routes.draw do
resources :users, only: [:new, :create, :show]
# ... your other routes
end
Create the registration form:
<!-- app/views/users/new.html.erb -->
<h1>Sign Up</h1>
<%= form_with(model: @user, local: true) do |form| %>
<% if @user.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@user.errors.count, "error") %> prohibited this user from being saved:</h2>
<ul>
<% @user.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= form.label :email %>
<%= form.email_field :email %>
</div>
<div class="field">
<%= form.label :password %>
<%= form.password_field :password %>
</div>
<div class="field">
<%= form.label :password_confirmation %>
<%= form.password_field :password_confirmation %>
</div>
<div class="actions">
<%= form.submit "Sign Up" %>
</div>
<% end %>
<%= link_to 'Login', login_path %> <!-- We'll create this route next -->
Update the Users controller:
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save
session[:user_id] = @user.id # Log them in after registration
redirect_to @user, notice: 'Account created successfully!'
else
render :new
end
end
def show
@user = User.find(params[:id])
end
private
def user_params
params.require(:user).permit(:email, :password, :password_confirmation)
end
end
Create a simple user profile page:
<!-- app/views/users/show.html.erb -->
<h1>Welcome, <%= @user.email %>!</h1>
<p>Account created: <%= @user.created_at.strftime("%B %d, %Y") %></p>
<%= link_to 'Logout', logout_path, method: :delete %>
Step 4: Create Login/Logout (Sessions)
Generate a sessions controller:
rails generate controller Sessions new create destroy
Add session routes:
# config/routes.rb
Rails.application.routes.draw do
resources :users, only: [:new, :create, :show]
# Session routes
get '/login', to: 'sessions#new'
post '/login', to: 'sessions#create'
delete '/logout', to: 'sessions#destroy'
# Optional: redirect root to login
root 'sessions#new'
end
Create the login form:
<!-- app/views/sessions/new.html.erb -->
<h1>Login</h1>
<% if flash[:alert] %>
<div class="alert"><%= flash[:alert] %></div>
<% end %>
<%= form_tag login_path do %>
<div class="field">
<%= label_tag :email %>
<%= email_field_tag :email %>
</div>
<div class="field">
<%= label_tag :password %>
<%= password_field_tag :password %>
</div>
<div class="actions">
<%= submit_tag "Login" %>
</div>
<% end %>
<%= link_to 'Sign Up', new_user_path %>
Implement the Sessions controller:
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def new
# Show login form
end
def create
user = User.find_by(email: params[:email])
if user && user.authenticate(params[:password])
session[:user_id] = user.id
redirect_to user, notice: 'Logged in successfully!'
else
flash.now[:alert] = 'Invalid email or password'
render :new
end
end
def destroy
session[:user_id] = nil
redirect_to login_path, notice: 'Logged out successfully!'
end
end
Step 5: Add Authentication Helpers
Create helper methods that all controllers can use:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
helper_method :current_user, :logged_in?
private
def current_user
@current_user ||= User.find(session[:user_id]) if session[:user_id]
end
def logged_in?
!!current_user
end
def require_login
unless logged_in?
flash[:alert] = 'You must be logged in to access this page'
redirect_to login_path
end
end
end
Helper method breakdown:
- current_user
: Returns the currently logged-in user or nil
- logged_in?
: Returns true/false if user is logged in
- require_login
: Redirects to login if not authenticated
The helper_method
line makes these methods available in views too.
Step 6: Protect Routes
Now you can protect any controller action:
# app/controllers/users_controller.rb
class UsersController < ApplicationController
before_action :require_login, only: [:show]
# ... rest of controller
end
Or protect an entire controller:
class TasksController < ApplicationController
before_action :require_login
# All actions require login
end
Step 7: Update Views with Authentication
Update your application layout to show login status:
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
<!-- ... head content -->
</head>
<body>
<nav>
<% if logged_in? %>
Welcome, <%= current_user.email %>!
<%= link_to 'Profile', current_user %>
<%= link_to 'Logout', logout_path, method: :delete %>
<% else %>
<%= link_to 'Login', login_path %>
<%= link_to 'Sign Up', new_user_path %>
<% end %>
</nav>
<% if notice %>
<div class="notice"><%= notice %></div>
<% end %>
<% if alert %>
<div class="alert"><%= alert %></div>
<% end %>
<%= yield %>
</body>
</html>
Step 8: Connect Users to Other Models
Update your Task model to belong to users:
rails generate migration AddUserToTasks user:references
rails db:migrate
Update the models:
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
has_many :tasks, dependent: :destroy
validates :email, presence: true, uniqueness: true
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 6 }, if: -> { new_record? || !password.blank? }
end
# app/models/task.rb
class Task < ApplicationRecord
belongs_to :user
# ... your existing validations and methods
end
Update controllers to scope tasks to current user:
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
before_action :require_login
def index
@tasks = current_user.tasks
end
def create
@task = current_user.tasks.build(task_params)
if @task.save
redirect_to tasks_path, notice: 'Task created!'
else
render :new
end
end
# ... other actions
end
Testing Your Authentication
Test the full flow:
- Sign up: Visit
/users/new
, create an account - Auto-login: Should redirect to profile after signup
- Logout: Click logout link
- Login: Visit
/login
, enter credentials - Protection: Try accessing protected pages when logged out
Common Issues and Solutions
1. Session Persists After Browser Close
# Make sessions expire when browser closes
# config/application.rb
config.session_store :cookie_store, expire_after: nil
2. Remember Login Longer
# In sessions#create
session[:user_id] = user.id
session.options[:expire_after] = 2.weeks
3. Case-Insensitive Email Login
# In sessions#create
user = User.find_by('LOWER(email) = ?', params[:email].downcase)
4. Redirect After Login
# Store intended destination
# app/controllers/application_controller.rb
def require_login
unless logged_in?
session[:intended_url] = request.url
redirect_to login_path
end
end
# In sessions#create after successful login
redirect_to session.delete(:intended_url) || current_user
Security Considerations
This basic authentication has some limitations:
- No session timeout - Sessions persist indefinitely
- No password reset - Users can't recover forgotten passwords
- No email confirmation - Emails aren't verified
- Basic CSRF protection only - Relies on Rails' built-in protection
- No rate limiting - Vulnerable to brute force attacks
For production apps, consider:
- Adding password reset functionality
- Implementing email confirmation
- Adding two-factor authentication
- Using secure session storage
- Implementing account lockout after failed attempts
What I Learned
Building authentication from scratch taught me:
- Sessions are just cookies - Rails manages them, but they're stored in the browser
- Never store plain text passwords - Always hash with bcrypt
- Validation happens in the model - Controllers coordinate, models validate
- Helper methods keep views clean -
current_user
is available everywhere - before_action is powerful - One line can protect entire controllers
Next Steps
Now you have a working authentication system! In the next post, we'll dive into Hotwire and see how to make this app feel modern with partial page updates and real-time features.
You could also extend this authentication system by adding:
- Password reset via email
- Email confirmation
- User profiles with additional fields
- Admin roles and permissions
Key takeaways:
- has_secure_password
handles password hashing
- Sessions store the user ID, not the user object
- Helper methods make authentication state available everywhere
- before_action
protects controller actions
- Always validate user input in models
Try building this authentication system in your own app. The patterns work for any Rails application!
Next up: "Why I Chose Hotwire Over React (As a Beginner)" - We'll explore modern frontend approaches and why server-side rendering made more sense for my learning journey.