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 password reset: the token is the bcrypt salt
Four controller actions, no new table, no column to clean up. Where the reset token comes from, what makes it single-use without anything storing it, and the two lines that decide whether the flow leaks your user list.
-
Rails 8 rate_limit: throttling sign-in without a gem
One line on SessionsController closes the password-guessing hole. What rate_limit counts, why the cache store decides whether it works at all, and the test configuration without which it silently never fires.
-
Rails session expiry: the cookie store, the Session row, and the timeout neither gives you
Rails 8 has two things called the session, and the answer to how do I expire it is different for each. What expire_after actually sets, why the generated Session record has no expiry column, and what a timeout costs once you add one.
-
Rails JWT API authentication with ActionController::API
Issuing and verifying a bearer token for a Rails 8 JSON API with ActionController::API, User.authenticate_by and the jwt gem, no authentication gem involved, and what a stateless token costs.
-
Email confirmation in Rails 8, without a confirmation column
Rails 8 email confirmation is not in the generator: it signs you in but never checks the address is real. generates_token_for closes that hole with no column, no gem and no stored token.
-
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 authentication used to mean Devise. Rails 8 ships a generator instead, and 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, and all four predate the Rails 8 generator and are usable without it.
That matters for reading the generated code. The generator does not replace these primitives, it composes them, so its fifty lines are legible exactly to the extent that you already recognise the four. Somebody who has met them reads the output as ordinary Rails; somebody who has not reads it as magic and reaches for Devise, which is a reasonable reaction to code you cannot follow.
None of the four is authentication on its own. They are the pieces. Every decision that actually decides whether your sign-in is any good, how long a session lives, whether the forms leak your user list, what happens on the fifth wrong password, is still yours to make, and the rest of this hub is those decisions.
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. What one line of
has_secure_passwordgives you takes all of it apart.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. Active Support's own description is "a thread-isolated attributes singleton, which resets automatically". The delegation withallow_nilmeans an anonymous visitor reads asnilrather than raising, which keeps everyif Current.userreadable.The user is not the only thing worth putting there. This codebase also carries the settings singleton, which turns a row read on every page into one read per request:
The admin calls
reset_cache!after an update so an edit takes effect on the next request rather than on the next deploy.Two costs come with it, and both are worth taking knowingly.
It is a global with a polite name. A model or service that reads
Current.userhas a dependency that does not appear in any method signature, which makes it harder to test and impossible to reuse from a context that has no request. This codebase does exactly that inSetting,Onboarding::FlowandAuth::OmniauthLink, and each of those is a deliberate trade rather than an accident.And a background job is not a request. Nothing populates
Currentthere, so a method that worked in a controller returnsnilthe day somebody moves it into a job, with no error to point at. Pass the user as an argument at that boundary.normalizes, applied on write and on queryApplied 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.It runs before validation, which is the detail that makes uniqueness real: the uniqueness validator and the unique index both see the same normalised string, rather than the validator seeing what the user typed and the index seeing something else.
There is one trap, and it is the kind that looks like the bug you added
normalizesto fix. It does not touch rows that are already in the table. Add it to a model whose column already holds mixed-case addresses and those rows stay exactly as they are, while the query side now downcases what it searches for, sofind_bystops finding them. Addingnormalizesto an existing table is therefore a migration and a line of code, never a line of code on its own.It is not only for emails. This codebase also normalises
Lead#nicknamewith a barestrip, which is enough to stop " alice" and "alice" being two different readers.What the Rails 8 authentication 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 the generator leaves you to write after sign-in
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, which email confirmation in Rails 8 walks through end to end.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.ActionController::API,authenticate_byand thejwtgem.None of it is difficult. All of it is time you are not spending on your product.
The trade-off against Devise
Devise or the Rails 8 generator is the choice this hub exists to settle, and the short version is that Devise 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.
Whichever path you take, the session it produces is the part neither of them expires. Rails session expiry separates the cookie store from the
Sessionrecord the generator creates, and works through why the generated one never ends on its own and what a timeout costs once you add it.Before any of that runs, something has to stop a script trying the password list. Rails 8 rate_limit is one line on the controller, and the article covers the two settings that decide whether it does anything at all: the cache store it counts in, and the test configuration that otherwise lets it pass every spec while protecting nothing.
The door for somebody who cannot get in is the one the generator leaves least explained. Rails 8 password reset covers where that token comes from, why the bcrypt salt is what makes the link single-use without any column storing it, and the two lines that decide whether the form quietly tells strangers which addresses have an account here.