35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rails 8 Authentication Generator: Build Production Sessions Without Devise

Rails 8 Authentication Generator: Build Production Sessions Without Devise

Roger Heykoop
Ruby on Rails, Security
Rails 8 authentication generator in production: sessions, password reset, rate limiting, OAuth and the migration path from Devise after nineteen years of Rails.

A SaaS founder messaged me last month asking if it was finally safe to ship a Rails 8 app without Devise. He had been burned by a Devise upgrade three years ago that broke his omniauth flow for a week, and he wanted to know if the new built-in generator was production-grade or just a demo. The short answer: yes, it is production-grade. The long answer is that the Rails 8 authentication generator is now the default I reach for on greenfield projects, and after nineteen years of Rails I have opinions about why.

Devise still has its place — if you need confirmable emails, lockable accounts, OmniAuth providers, and account invitations out of the box, it is still less code than rolling your own. But for the seventy percent of apps that just need email + password + sessions + password reset, the Rails 8 authentication generator gives you a foundation you actually own and can read end to end in twenty minutes.

What the Rails 8 Authentication Generator Actually Generates

Run bin/rails generate authentication in a Rails 8 app and you get a small, opinionated stack: a User model with has_secure_password, a Session model that records cookies and IP addresses, a PasswordsMailer, and three controllers — SessionsController, PasswordsController, and a Concerns::Authentication module that you include in ApplicationController. That is it. No engine, no DSL, no devise_for :users route that hides ten controllers behind one line.

The session model is the part that makes the Rails 8 authentication generator more than a toy. Instead of storing a user id in the cookie session and trusting it, the generator creates a sessions table with a user_id, user_agent, and ip_address, and stores a signed cookie containing only the session id. Sign-out destroys the row. Stolen cookies stop working the moment you delete the session. Building the same thing on Devise takes a custom warden strategy and a few weekends.

class Session < ApplicationRecord
  belongs_to :user
end

class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy

  normalizes :email_address, with: ->(e) { e.strip.downcase }

  validates :email_address, presence: true, uniqueness: true
end

That is the whole core of the Rails 8 authentication generator. Read it, understand it, change it. No magic.

Why I Stopped Reaching for Devise on Greenfield Projects

Devise is one of the best gems in the Rails ecosystem. I have used it in production since 2010 and it has carried me through dozens of apps. But it has accumulated a kind of friction that the Rails 8 authentication generator avoids by design.

When something breaks in Devise, you debug through controllers you did not write, modules mixed into modules, and a routing layer that is intentionally opaque. I have spent entire afternoons tracing why a before_action ran twice, or why current_user was nil inside a callback that fired after sign_in. The Rails 8 authentication generator has roughly two hundred lines of code total. When something breaks you open the file, fix it, and move on.

Devise also makes hard things easy and easy things weird. Adding rate limiting to login? You patch a controller you do not own. Adding a custom claim to the session cookie? You override a Warden strategy. Logging every successful sign-in to your audit table? You hook into a Devise event. With the Rails 8 authentication generator you just edit SessionsController#create. The mental model collapses.

The trade-off is honest: if you need OmniAuth for Google and GitHub, account confirmation emails, account locking after N failed attempts, and the ability for admins to impersonate users, Devise plus a few extensions is still less code. Pick the right tool. But do not reach for Devise reflexively.

Adding Rate Limiting to the Rails 8 Authentication Generator

The generator does not ship with rate limiting on the login endpoint. That is the first thing I add in production. Rails 8 ships ActionController::RateLimiting and the integration is one block:

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_url, alert: "Try another email address or password."
    end
  end

  def destroy
    terminate_session
    redirect_to new_session_url
  end
end

Ten attempts per three minutes per IP is a reasonable default for a B2B app. For consumer apps with shared NAT (think corporate networks behind a single egress IP) you will want to key on email + IP rather than just IP, otherwise legitimate users will lock each other out. That is a five-line change to the rate limiter:

rate_limit to: 10, within: 3.minutes, only: :create,
           by: -> { "#{request.remote_ip}:#{params.dig(:email_address)&.downcase}" },
           with: -> { redirect_to new_session_url, alert: "Try again later." }

Pair this with Rack::Attack at the edge for a coarser layer (block IPs after a thousand requests in five minutes regardless of route) and you have defense in depth.

Password Reset That Does Not Leak User Existence

The Rails 8 authentication generator’s PasswordsController is good but the default create action will tell an attacker whether an email exists in your database, depending on how you flash the message. Fix this in the controller:

class PasswordsController < ApplicationController
  allow_unauthenticated_access
  before_action :set_user_by_token, only: %i[ edit update ]

  def create
    if user = User.find_by(email_address: params[:email_address])
      PasswordsMailer.reset(user).deliver_later
    end
    redirect_to new_session_path,
                notice: "If that email exists, we sent password reset instructions."
  end

  def update
    if @user.update(params.permit(:password, :password_confirmation))
      redirect_to new_session_path, notice: "Password has been reset."
    else
      redirect_to edit_password_path(params[:token]),
                  alert: "Passwords did not match."
    end
  end

  private
    def set_user_by_token
      @user = User.find_by_password_reset_token!(params[:token])
    rescue ActiveSupport::MessageVerifier::InvalidSignature
      redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
    end
end

Same response whether the email exists or not. Same timing (the find_by is fast enough that the difference is below network noise). Reset tokens are signed with a fifteen minute expiry via the generator’s use of generates_token_for. That is the bar; do not ship below it.

Session Management People Actually Use

The Rails 8 authentication generator stores a row per session, which means you can build a “manage your devices” page in about thirty lines. This is a feature your users will actually appreciate after they leave their laptop on a plane:

class SessionsController < ApplicationController
  before_action :resume_session, only: :index
  before_action :require_authentication, only: %i[ index destroy ]

  def index
    @sessions = Current.user.sessions.order(created_at: :desc)
  end

  def destroy
    Current.user.sessions.find(params[:id]).destroy
    redirect_to sessions_path, notice: "Signed out from that device."
  end
end

In the view, render session.user_agent parsed through a tiny gem like useragent so users see “Chrome on macOS” instead of a Mozilla string from 1998. Highlight the current session. Add a “Sign out everywhere” button that calls Current.user.sessions.destroy_all and you have parity with what Google and GitHub offer.

The Rails 8 authentication generator makes this cheap because the session model is yours. If you want to record last_seen_at on every authenticated request, add a column and update it from resume_session. Try doing that cleanly through Devise’s Warden integration sometime.

Adding OAuth Without Pulling in OmniAuth

The Rails 8 authentication generator has no opinion on OAuth, and that is fine. For most apps that need “Sign in with Google” you do not need the OmniAuth ecosystem — you need three things: an authorize redirect, a callback that exchanges the code for a token, and a user lookup or creation step.

class Oauth::GoogleController < ApplicationController
  allow_unauthenticated_access

  def show
    redirect_to "https://accounts.google.com/o/oauth2/v2/auth?" + {
      client_id: Rails.application.credentials.google.client_id,
      redirect_uri: oauth_google_callback_url,
      response_type: "code",
      scope: "openid email profile",
      state: form_authenticity_token
    }.to_query, allow_other_host: true
  end

  def callback
    raise ActionController::InvalidAuthenticityToken if params[:state] != form_authenticity_token

    token  = exchange_code(params[:code])
    claims = JWT.decode(token["id_token"], nil, false).first

    user = User.find_or_create_by!(email_address: claims["email"]) do |u|
      u.password = SecureRandom.hex(32)
    end

    start_new_session_for user
    redirect_to after_authentication_url
  end

  private
    def exchange_code(code)
      Net::HTTP.post_form(URI("https://oauth2.googleapis.com/token"), {
        code: code, client_id: Rails.application.credentials.google.client_id,
        client_secret: Rails.application.credentials.google.client_secret,
        redirect_uri: oauth_google_callback_url, grant_type: "authorization_code"
      }).then { |r| JSON.parse(r.body) }
    end
end

A hundred lines and you have Google sign-in that integrates with the Rails 8 authentication generator’s session model. Add GitHub with the same template. Add Microsoft when a customer asks. You will write more code than installing omniauth-google-oauth2, but you will read every line and your CSP and CSRF behavior will match the rest of the app. This is the kind of trade-off the Rails 8 authentication generator pushes you toward — own the surface that matters.

For details on storing those credentials safely, see Rails Credentials and Secrets Management for Production.

Migrating from Devise to the Rails 8 Authentication Generator

If you have an existing Devise app and want to move to the Rails 8 authentication generator, the migration is mechanical but boring. Do not do it during a feature push.

The password column is the easy part. Devise stores BCrypt hashes in encrypted_password and has_secure_password stores them in password_digest. They are the same algorithm and cost. Add a column, copy the data, drop the old one in a follow-up deploy:

class MigrateDeviseToHasSecurePassword < ActiveRecord::Migration[8.0]
  def up
    add_column :users, :password_digest, :string
    User.in_batches.update_all("password_digest = encrypted_password")
  end
end

The hard part is sessions. Devise users are signed in via a cookie that maps to a user id directly. The Rails 8 authentication generator uses a session row. Cut over by deploying both side by side: keep Devise active for existing logins, route new logins through the new SessionsController, and add a before_action that detects a Devise cookie, creates a Session row for it, and signs the user out of Devise. Within a week your users will all have migrated naturally, and you can drop Devise from the Gemfile.

If you have built custom Warden strategies or rely on Devise modules like :trackable or :lockable, port them deliberately. trackable becomes columns on User updated from resume_session. lockable becomes a failed_attempts counter and a locked_until timestamp checked in User.authenticate_by. None of this is hard. It is just work, and it is work you will be glad you did the next time something breaks at 2am.

For the broader playbook on staged Rails work like this, see Rails Upgrade: An Incremental Strategy.

Production Hardening Checklist

Before you call the Rails 8 authentication generator production-ready, run through this list. None of it is optional for a real app:

  • Force HTTPS with config.force_ssl = true and HSTS headers. Sessions sent over HTTP are sessions stolen on coffee shop wifi.
  • Set the session cookie SameSite to Lax, or Strict if you do not need cross-site navigation flows. The generator uses Lax by default; do not weaken it.
  • Hash reset tokens at rest if you store them. The generator uses signed tokens that are not stored, which is correct — do not change this.
  • Log authentication events to your audit table from start_new_session_for and terminate_session. You will want this the first time a customer asks “did anyone log in to my account from Russia?”
  • Send sign-in notification emails for new devices. Compare user_agent and ip_address against the user’s recent sessions and email them on a mismatch.
  • Rotate the session cookie on privilege escalation (when an admin role is granted, when a password is changed). terminate_session; start_new_session_for(user) does this in one line.
  • Test password reset end-to-end with an integration test that includes the email body. The number of apps shipping broken reset flows is depressing.
  • Add CAPTCHAs for repeated failures, not for every login. CAPTCHAs on every sign-in are a UX disaster; CAPTCHAs after three failed attempts are reasonable.

Do not skip the rate limiting and the audit logging. Those are the two that come up in every security review and the two that are easiest to add correctly while you are still building.

Frequently Asked Questions

Is the Rails 8 authentication generator production-ready?

Yes, with two caveats: add rate limiting on the login and password reset endpoints, and add audit logging on session create and destroy. The generator gives you a session model, BCrypt password hashing via has_secure_password, signed reset tokens, and CSRF protection out of the box. That is the same foundation Devise sits on. The remaining work is the same hardening work you would do on any auth stack.

Should I migrate my existing Devise app to the Rails 8 authentication generator?

Probably not unless you have a reason. Devise is stable and well-maintained, and a migration is real work for no user-visible benefit. Migrate when you are already touching authentication for another reason — adding OAuth, simplifying a tangled session flow, or removing a module of Devise you no longer need. Greenfield projects are a different story; reach for the Rails 8 authentication generator first.

How does the Rails 8 authentication generator compare to Devise for OmniAuth?

Devise plus devise-omniauth is less code if you need three or more OAuth providers. The Rails 8 authentication generator has no OmniAuth integration, so you write the OAuth flow yourself per provider. For one or two providers, doing it yourself is fine and gives you full control over the callback. For five providers across enterprise SSO, SAML, and social login, install OmniAuth and connect it to the generator’s session model.

Can I use the Rails 8 authentication generator with API-only Rails apps?

Yes, but you will replace the cookie session storage with token storage. Keep the sessions table; instead of storing the session id in a signed cookie, return it as a bearer token in the Authorization header. The session row, password hashing, and reset flow stay identical. This gets you token-based API auth without a separate gem like devise-jwt.

Need help shipping production-ready authentication on Rails 8, or migrating off a tangled Devise setup? TTB Software specializes in Rails security, architecture, and fractional CTO work for growth-stage SaaS. We’ve been doing this for nineteen years.

#rails-8-authentication-generator #rails-authentication-without-devise #rails-sessions-cookies #rails-password-reset #rails-bcrypt-has-secure-password #rails-authentication-rate-limiting #ruby-on-rails
R

About the Author

Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation