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:
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
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.
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:
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.
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:
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.
The Rails 8 authentication generator gives you a
Sessionrow, 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_tokencolumnMost tutorials still reach for a column. Add
confirmation_tokenandconfirmed_atto 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_forremoves all three. It signs the token instead of storing it, so there is nothing in the database to leak and nothing to clean up: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_addresstherefore means every outstanding confirmation link dies the moment the address changes. That is exactly what you want. A user registers asjane@old.example, receives a link, changes their address tojane@new.examplebefore 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_passwordsets 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:
find_by_token_for!raisesActiveSupport::MessageVerifier::InvalidSignaturefor anything that does not verify, and that single exception covers every failure worth distinguishing: a truncated token, a token someone edited, a token past itsexpires_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_forreturnsnil, 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
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 moveconfirmed_atforward 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:
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
Rails 8 ships
rate_limitin 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:
Putting the check here rather than in a
before_actionon 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.