LaunchKit

Email confirmation in Rails 8, without a confirmation column

September 10, 2026

The Rails 8 authentication generator gives you a Session row, a signed cookie and a password reset. It does not give you email confirmation. Nothing warns you: registration succeeds, the user is signed in, and the address they typed is never checked against anyone who can actually read mail at it. If you are still weighing the generator against a gem, the comparison with Devise covers that choice; this page assumes the generator and closes the hole.

Why there is no confirmation_token column

Most tutorials still reach for a column. Add confirmation_token and confirmed_at to the users table, generate a random string on create, store it, look it up on the way back. That was the right answer before Rails 7.1, and it is now the wrong one for three reasons: the token sits in your database as a live credential, it needs its own expiry logic, and it needs invalidating by hand whenever the thing it authorises changes.

generates_token_for removes all three. It signs the token instead of storing it, so there is nothing in the database to leak and nothing to clean up:

class User < ApplicationRecord
  generates_token_for :email_confirmation, expires_in: 1.day do
    email_address
  end
end

You keep one column, confirmed_at, and it holds a timestamp rather than a secret.

What the block returns, and why it is the address

The block is the part worth understanding, because it is where the security property lives. Whatever it returns is signed into the token and compared again at verification time. If the value has changed in between, the token no longer matches and verification fails.

Returning email_address therefore means every outstanding confirmation link dies the moment the address changes. That is exactly what you want. A user registers as jane@old.example, receives a link, changes their address to jane@new.example before clicking, and the old link stops working: confirming it would have marked the new address as verified on the strength of an email sent to the old one.

The same mechanism is what invalidates a password reset when the password changes, which has_secure_password sets up for you with the digest as the block's return value.

Looking the user up, and the one exception to rescue

Verification is a class method on the other side:

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

find_by_token_for! raises ActiveSupport::MessageVerifier::InvalidSignature for anything that does not verify, and that single exception covers every failure worth distinguishing: a truncated token, a token someone edited, a token past its expires_in, and a token whose signed value has moved on. There is no separate expiry check to write, because expiry is signed into the token itself.

The bang version is the one to use. The non-bang find_by_token_for returns nil, which invites a controller that treats "no such token" and "expired token" as different paths and leaks the difference to whoever is guessing.

The confirming action, and why it is idempotent

def show
  user = User.find_by_email_confirmation_token!(params[:token])
  user.confirm! unless user.confirmed?
  redirect_to new_session_path, notice: t("auth.email_confirmations.flash.confirmed")
rescue ActiveSupport::MessageVerifier::InvalidSignature
  redirect_to new_session_path, alert: t("auth.email_confirmations.flash.invalid")
end

unless user.confirmed? is not defensive noise. Mail clients prefetch links, corporate scanners follow them before the human does, and people forward the message to themselves. The action has to survive being called several times, and the second call must not move confirmed_at forward or the timestamp stops meaning "when this address was verified".

Note what the action does not do: it does not sign anyone in. A link that both confirms and authenticates turns an email in an inbox into a login, and inboxes get forwarded.

Resending, and why both answers are identical

The resend form is where a confirmation flow usually leaks:

def create
  user = User.find_by(email_address: params[:email_address])
  ConfirmationMailer.confirm(user).deliver_later if user && !user.confirmed?
  redirect_to new_session_path, notice: t("auth.email_confirmations.flash.resent")
end

The redirect is outside the conditional, and that is the whole point. An unknown address and a known one produce the same response, so the form cannot be used to ask "does this person have an account here". A version that renders "we could not find that address" is an enumeration oracle, and it is the default shape almost every tutorial writes.

The condition also refuses to resend to an already confirmed user, which stops the form being used to send repeated mail to someone who has finished with it.

Rate limiting, on the thing being attacked

rate_limit to: 10, within: 3.minutes, only: :create, with: -> { rate_limited }

Rails 8 ships rate_limit in the controller, keyed on the requester's IP by default. This endpoint sends mail to an address supplied by whoever posts the form, so without a limit it is a mail bomb you host on someone else's behalf: type a stranger's address, press the button repeatedly, and your domain does the sending. Reputation damage lands on you, not on the attacker.

Where the gate belongs

A confirmation column is worth nothing until something refuses to proceed without it. The cheapest correct place is the one action that turns a password into a session:

if user.nil?
  redirect_to new_session_path, alert: t("auth.sessions.flash.invalid")
elsif user.confirmed?
  start_new_session_for user
  redirect_to after_authentication_url
else
  redirect_to new_session_path, alert: t("auth.sessions.flash.unconfirmed")
end

Putting the check here rather than in a before_action on every controller means there is one place to get right and no page that quietly forgets it. It also keeps the three outcomes distinct in the code while the two failures look the same to the user.

What this codebase does around email confirmation

Registration sends the mail and never opens a session, so the "check your inbox" page is the only thing a new account sees. The mailer generates the token at send time rather than accepting one, so no caller can invent a token. The JSON API applies the same confirmed? test before issuing a bearer token, because an API that skips the gate makes the web gate decorative. And the resend endpoint carries the rate limit above, keyed per IP, on the same helper the password reset uses so both throttles behave identically.

More on Rails 8 authentication

← All Rails 8 authentication articles