Rails 8 native authentication vs Devise
September 06, 2026
More on Rails 8 authentication
- Rails 8 password reset: the token is the bcrypt salt
- Rails 8 rate_limit: throttling sign-in without a gem
- Rails session expiry: the cookie store, the Session row, and the timeout neither gives you
- Rails JWT API authentication with ActionController::API
- Email confirmation in Rails 8, without a confirmation column
- has_secure_password: everything one line gives you
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
Sessionmodel belonging to a user, aSessionscontroller, and anAuthenticationconcern included intoApplicationController. The concern is about fifty lines. Here is the half that matters.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:
Read that cookie call slowly, because three security decisions are packed into it.
signedmeans 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.httponlymeans 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: :laxmeans 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 Rails primitives the generator leans on
The generator is short because Rails does the heavy lifting elsewhere.
ActiveSupport::CurrentAttributesholds the session for the duration of the request: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_nilon the delegation is whyCurrent.userreads as nil for an anonymous visitor instead of raising.has_secure_passwordcovers hashing, the confirmation validation, the 72-byte limit and the password reset token. It hides more than any other line in the model, and it has its own page.normalizeskeeps lookups honest:It applies on write and on query, so a user who signs up as
Alice@Example.comand signs in asalice@example.comis 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
The sessions table is the argument for the design, and it is not "fewer dependencies".
A session you can see is a session you can revoke.
session.destroysigns 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_agentandip_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. Anduser.sessions.destroy_allafter 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.
generates_token_forhands you a signed expiring token. The mailer, the controller, and the rule about what an unconfirmed user may reach are yours, and confirming an address without a confirmation column is where those decisions get made.before_action :require_authenticationis 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.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.
Choosing between Devise and the Rails 8 generator
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.