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 8 ships an authentication generator. 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.

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. There is a full article on all of this in this hub.

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

normalizes

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.

What the 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 it does not give you

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.
  • 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.

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.

Articles on this topic