Rails 8 authentication
Sessions, tokens, password reset and email confirmation built on the Rails 8 generator and the framework primitives underneath it. No Devise.
Articles on this topic
-
Rails 8 native authentication vs Devise
What the generator actually produces, which framework primitives it leans on, what Devise still does better, and how to choose without reopening the argument every project.
-
has_secure_password: everything one line gives you
The validations, the 72-byte limit, the challenge attribute nobody uses, and the 15-minute reset token. Plus how this codebase wires it up.
Rails 8 ships an authentication generator. It gives you a
Sessionmodel, a signed cookie and about fifty lines of concern. What it does not give you is everything that surrounds those fifty lines, and that gap is where the time goes.This hub covers both halves: the framework primitives Rails hands you, and the decisions you still have to make on top of them.
The primitives Rails already gives you
Before writing anything, it is worth knowing what is already there. Four pieces do most of the work.
has_secure_passwordOne line on the model:
It requires a
password_digestcolumn and the bcrypt gem, and it brings in more than most people realise: thepasswordandpassword_confirmationaccessors, anauthenticateinstance method,User.authenticate_by, apassword_challengeattribute, three validations you did not write, and a password reset token.authenticate_bymatters more than it looks. It performs the digest comparison whether or not the record exists, in constant time, which is what stops an attacker enumerating your users by timing the response to a login attempt.The reset token is on by default and is valid for 15 minutes, a number worth knowing before a user reports that the link in their inbox stopped working. It is signed and carries its own expiry, so there is no column to store, no index to maintain and no cleanup job. Changing the password invalidates outstanding links, so an old email cannot be replayed after the account is already recovered. The window is configurable:
The validations are worth naming too, because one of them is a real trap: the password may be at most 72 bytes, because bcrypt truncates beyond that. Without the check, a long passphrase would have its tail silently ignored. There is a full article on all of this in this hub.
generates_token_forThe same mechanism, available for anything you need a link for:
The block is the interesting part. Whatever it returns is baked into the token and checked when the token is used, so a confirmation link is tied to the address it was sent to. Change the address and the outstanding link is dead. Nothing is stored, nothing expires in a database, and there is no token column to leak.
Reading it back raises rather than returning nil, which is the behaviour you want at a controller boundary:
ActiveSupport::CurrentAttributesRequest-scoped state without passing a user through every method signature:
Current.useris available anywhere in the request, and Rails resets it between requests so it cannot leak across them in a threaded server. The delegation withallow_nilmeans an anonymous visitor reads asnilrather than raising, which keeps everyif Current.userreadable.normalizesApplied on write and on query, so
User.find_by(email_address: " Alice@Example.com ")finds the record stored asalice@example.com. Without it, uniqueness is a lie and half your support requests are people who signed up twice with the same address in different case.What the generator gives you
Session handling is one concern. The part that matters:
A session is a database row. The cookie holds its id, signed so it cannot be forged, and every request looks it up.
Two consequences follow, and both are why this design is worth having. A session you can see in the database is a session you can revoke:
Session#destroylogs someone out for real, from every device, with no token blacklist to maintain. And because the row carriesuser_agentandip_address, "sign out my other devices" is a feature you can build rather than one you have to bolt on.What it does not give you
The generator stops at sign-in. Everything a product needs after that, you write:
generates_token_forhands you the token. You still write the mailer, the controller, and the decision about what an unconfirmed user may reach in the meantime.before_action :require_authenticationis easy. The opt-out for public pages and the return-to round trip after a login are the fiddly part.rate_limitbuilt in, but deciding what to key it on is yours.None of it is difficult. All of it is time you are not spending on your product.
The trade-off against Devise
Devise answers 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.
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 rather than your own code. The generator's fifty lines are in your repository, and so is every decision layered on top.
Take the native path when you want the mechanism legible, and Devise when you want to stop thinking about it. This boilerplate takes the first road and writes down everything that follows.