LaunchKit

Rails 8 native authentication vs Devise

September 06, 2026

Rails 8 ships bin/rails generate authentication. The question is no longer "which gem", it is "gem or no gem", and the honest answer depends on something other than a feature list.

What the generator actually produces

Three things: a Session model belonging to a user, a Sessions controller, and an Authentication concern included into ApplicationController. The concern is about fifty lines. Here is the half that matters.

def resume_session
  Current.session ||= find_session_by_cookie
end

def find_session_by_cookie
  Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end

A session is a database row. The cookie holds its id. Every request looks it up. That is the entire mechanism, and it is worth pausing on how little there is.

Creating one:

def start_new_session_for(user)
  user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
    Current.session = session
    cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
  end
end

Read that cookie call slowly, because three security decisions are packed into it.

signed means the value is tamper-proof: change the id in your browser and Rails rejects it. It is not encrypted, so do not put secrets there, but it cannot be forged.

httponly means page JavaScript cannot read it. An XSS on your site is bad; an XSS that can exfiltrate session cookies is an account takeover.

same_site: :lax means the cookie does not ride along on cross-site POST requests. That is CSRF defence in depth, underneath the authenticity token Rails already checks.

The primitives it leans on

The generator is short because Rails does the heavy lifting elsewhere.

ActiveSupport::CurrentAttributes holds the session for the duration of the request:

class Current < ActiveSupport::CurrentAttributes
  attribute :session
  delegate :user, to: :session, allow_nil: true
end

Rails resets it between requests, which is the part that matters on a threaded server: without that reset, one request's user leaks into another's response. The allow_nil on the delegation is why Current.user reads as nil for an anonymous visitor instead of raising.

has_secure_password covers hashing, the confirmation validation, the 72-byte limit and the password reset token. There is a whole article on it in this hub.

normalizes keeps lookups honest:

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

It applies on write and on query, so a user who signs up as Alice@Example.com and signs in as alice@example.com is one account rather than two. Skip it and your uniqueness validation is decorative.

rate_limit, new in Rails 8, throttles the endpoints that take an email address without pulling in a gem.

What sessions in a table buy you

This is the argument for the design, and it is not "fewer dependencies".

A session you can see is a session you can revoke. session.destroy signs someone out for real, immediately, everywhere. There is no token blacklist to maintain, no window during which a revoked JWT still works, and no clock skew to reason about.

Because the row carries user_agent and ip_address, "here are your active sessions, sign out the one in Berlin" is a feature you can build in an afternoon rather than an architecture change. And user.sessions.destroy_all after a password reset is one line, which is exactly what you want when someone is resetting because they think they were compromised.

The cost is a database read per request. On any app that already loads a user per request, it is the same read you were doing anyway.

What the generator does not do

Everything after sign-in.

  • Email confirmation. generates_token_for hands you a signed expiring token. The mailer, the controller, and the rule about what an unconfirmed user may reach are yours.
  • Password reset. Rails 8 gives you the token and its fifteen-minute expiry. The flow is yours.
  • The gate. before_action :require_authentication is trivial. The opt-out for public pages, and remembering where the visitor was going so you can send them back after login, is the fiddly part.
  • Onboarding. A signed-in user who has not finished setup is a third state, and every controller has to agree on what it means.
  • Social sign-in. Not even attempted. That is OmniAuth and its own set of decisions.

None of it is hard. All of it is time, and a long tail of edge cases you meet in production rather than in review: the unconfirmed user who reaches a page they should not, the reset link that still works after the password changed.

So which one

Devise does all of the above and has been hardened by fifteen years of everyone else's bugs. That is a real argument and it does not go away because Rails shipped a generator.

The argument the other way is ownership. Devise's controllers are yours to override, not to read. When something behaves unexpectedly you are reading a gem's source and its configuration DSL instead of your own code, and the answer is usually a module you did not know was included. The generator's fifty lines are in your repository, and so is every decision layered on top.

Take Devise when authentication is not where you want to spend attention. Take the native path when you want the mechanism legible. Neither is wrong.

What is wrong is rebuilding the second one from scratch on every new project, which is the part this boilerplate exists to stop.

More on Rails 8 authentication

← All Rails 8 authentication articles