DEV Community

Cover image for You Don't Need Devise: Building Secure Rails Auth from Scratch
Zil Norvilis
Zil Norvilis

Posted on

You Don't Need Devise: Building Secure Rails Auth from Scratch

The "Devise" Dilemma

We’ve all been there. You run rails new, and the very next command is setting up Devise. It’s the standard. It’s battle-tested.

But sometimes, it’s overkill.

Devise creates over 10 tables/routes/views you might not need. And the moment you want to customize a controller or change how the session behaves, you find yourself digging through documentation to override the "magic."

Rails actually ships with a built-in, secure authentication mechanism that takes about 15 minutes to set up. It’s called has_secure_password.

Here is how to build a production-ready login system with zero external auth dependencies.

Step 1: The Setup (Bcrypt)

Rails uses the bcrypt hashing algorithm to secure passwords. It’s likely already in your Gemfile, just commented out.

# Gemfile
gem 'bcrypt', '~> 3.1.7'
Enter fullscreen mode Exit fullscreen mode

Run bundle install.

Step 2: The Model

We don't store passwords. We store a digest (a hashed version) of the password. Rails looks for a column specifically named password_digest.

Generate your User model:

rails g model User email:string password_digest:string
rails db:migrate
Enter fullscreen mode Exit fullscreen mode

Now, enable the magic in your model:

# app/models/user.rb
class User < ApplicationRecord
  # Adds methods to set and authenticate against a BCrypt password. 
  # This mechanism requires you to have a password_digest attribute.
  has_secure_password

  validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :password, length: { minimum: 6 }, if: -> { new_record? || !password.nil? }
end
Enter fullscreen mode Exit fullscreen mode

What did has_secure_password just do?

  1. It adds virtual attributes for password and password_confirmation.
  2. It validates that those two match.
  3. It hashes the password and stores it in password_digest upon save.
  4. It gives you an authenticate method.

Step 3: Registration (Sign Up)

Let's create a controller to handle signing up.

# config/routes.rb
get '/signup', to: 'registrations#new'
post '/signup', to: 'registrations#create'
Enter fullscreen mode Exit fullscreen mode
# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)
    if @user.save
      # Log them in automatically after sign up
      session[:user_id] = @user.id
      redirect_to root_path, notice: "Welcome to the app!"
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def user_params
    params.require(:user).permit(:email, :password, :password_confirmation)
  end
end
Enter fullscreen mode Exit fullscreen mode

Step 4: Sessions (Log In / Log Out)

This is where beginners often get confused. "Logging in" isn't a database change; it's a browser state change. We store the user_id in the browser's cookie (the session).

# config/routes.rb
get '/login', to: 'sessions#new'
post '/login', to: 'sessions#create'
delete '/logout', to: 'sessions#destroy'
Enter fullscreen mode Exit fullscreen mode
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def new
  end

  def create
    user = User.find_by(email: params[:email])

    # The magic 'authenticate' method comes from has_secure_password
    if user && user.authenticate(params[:password])
      session[:user_id] = user.id
      redirect_to root_path, notice: "Logged in successfully!"
    else
      flash.now[:alert] = "Invalid email or password"
      render :new, status: :unprocessable_entity
    end
  end

  def destroy
    session[:user_id] = nil
    redirect_to root_path, notice: "Logged out."
  end
end
Enter fullscreen mode Exit fullscreen mode

Step 5: The "Current User" Helper

Finally, we need to access the logged-in user from anywhere in the app. We do this in the ApplicationController.

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  helper_method :current_user, :logged_in?

  def current_user
    # Memoization: only fetch from DB if @current_user is nil
    @current_user ||= User.find(session[:user_id]) if session[:user_id]
  end

  def logged_in?
    !!current_user
  end

  def require_user
    unless logged_in?
      redirect_to login_path, alert: "You must be logged in to do that."
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Why do this?

  1. Understanding: You now know exactly how cookies and password hashing interact.
  2. Performance: No extra middleware or modules loading in the background.
  3. Flexibility: Want to allow login via Username OR Email? Just change one line in SessionsController. Want to add 2FA later? It's easier to build on top of this simple logic than to fight Devise's warden hooks.

Devise is fantastic for enterprise apps with complex OmniAuth needs. But for your next side project? Try rolling your own.


Have you ditched Devise recently? Tell me about your stack in the comments!

Top comments (0)