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.
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.
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:
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.
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.
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 primitives it 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. There is a whole article on it in this hub.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
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.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.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.
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.