Rails Development

Adding User Authentication Without Gems

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:

  1. Sign up: Visit /users/new, create an account
  2. Auto-login: Should redirect to profile after signup
  3. Logout: Click logout link
  4. Login: Visit /login, enter credentials
  5. 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:

  1. No session timeout - Sessions persist indefinitely
  2. No password reset - Users can't recover forgotten passwords
  3. No email confirmation - Emails aren't verified
  4. Basic CSRF protection only - Relies on Rails' built-in protection
  5. 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:

  1. Sessions are just cookies - Rails manages them, but they're stored in the browser
  2. Never store plain text passwords - Always hash with bcrypt
  3. Validation happens in the model - Controllers coordinate, models validate
  4. Helper methods keep views clean - current_user is available everywhere
  5. 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.

Christopher Lim

Christopher Lim

Rails developer and Unity explorer. Family man, lifelong learner, and builder turning ideas into polished applications. Passionate about quality software development and continuous improvement.

Back to All Posts
Reading time: 9 min read

Enjoyed this rails development post?

Follow me for more insights on rails development, Rails development, and software engineering excellence.