LaunchKit

Rails 8 authentication

Sessions, tokens, password reset and email confirmation built on the Rails 8 generator and the framework primitives underneath it. No Devise.

Rails authentication used to mean Devise. Rails 8 ships a generator instead, and it gives you a Session model, 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_password

One line on the model:

has_secure_password

It requires a password_digest column and the bcrypt gem, and it brings in more than most people realise: the password and password_confirmation accessors, an authenticate instance method, User.authenticate_by, a password_challenge attribute, three validations you did not write, and a password reset token.

authenticate_by matters 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:

has_secure_password reset_token: { expires_in: 1.hour }

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_password gives you takes all of it apart.

generates_token_for

The same mechanism, available for anything you need a link for:

generates_token_for :email_confirmation, expires_in: 1.day do
  email_address
end

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:

def self.find_by_email_confirmation_token!(token)
  find_by_token_for!(:email_confirmation, token)
end

ActiveSupport::CurrentAttributes

Request-scoped state without passing a user through every method signature:

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

Current.user is 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 with allow_nil means an anonymous visitor reads as nil rather than raising, which keeps every if Current.user readable.

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:

def self.current
  Current.setting ||= first_or_create!
end

def self.reset_cache! = Current.setting = nil

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.user has 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 in Setting, Onboarding::Flow and Auth::OmniauthLink, and each of those is a deliberate trade rather than an accident.

And a background job is not a request. Nothing populates Current there, so a method that worked in a controller returns nil the 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 query

One find_by call reaches two rows differently once normalizes is added to an existing table. The query is normalized to alice@example.com, so a row written after normalizes was added is stored as alice@example.com and is returned, while a row that was already in the table is still stored as Alice@Example.com and is never found again.

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

Applied on write and on query, so User.find_by(email_address: " Alice@Example.com ") finds the record stored as alice@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 normalizes to 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, so find_by stops finding them. Adding normalizes to 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#nickname with a bare strip, 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:

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, 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#destroy logs someone out for real, from every device, with no token blacklist to maintain. And because the row carries user_agent and ip_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:

  • Email confirmation. generates_token_for hands 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.
  • Password reset. Rails 8 gives you the token; the flow around it is yours.
  • The gate. before_action :require_authentication is easy. The opt-out for public pages and the return-to round trip after a login are the fiddly part.
  • Onboarding. A signed-in user who has not finished setup is a state, and every controller has to agree on what it means.
  • Rate limiting. Rails 8 has rate_limit built in, but deciding what to key it on is yours.
  • A token API. A signed session cookie serves a browser, while a mobile or server client wants a bearer token, and JWT authentication for a Rails JSON API builds one out of ActionController::API, authenticate_by and the jwt gem.

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 Session record 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.

Articles on this topic