One of the quieter additions in Rails 7.1 is also one of the most elegant: generates_token_for and its companion find_by_token_for. Together, they give you stateless, purpose-bound, self-invalidating tokens without a single extra database column or table.
Requires Rails 7.1+. The
generates_token_forAPI was introduced in Rails 7.1 via PR #44189. If you're on an earlier version, you'll need to upgrade or fall back tosigned_id.
If you've ever built a password reset flow, you know the old pattern well:
# The old way: random token + database column + expiry check + cleanup
class User < ApplicationRecord
def generate_password_reset
update!(
reset_token: SecureRandom.urlsafe_base64,
reset_sent_at: Time.current
)
end
def self.find_by_valid_reset_token(token)
user = find_by(reset_token: token)
return nil unless user
return nil if user.reset_sent_at < 15.minutes.ago
user
end
def clear_password_reset
update!(reset_token: nil, reset_sent_at: nil)
end
end
That's a migration, three methods, and a manual cleanup step for something conceptually simple. With generates_token_for, the same feature looks like this:
# The new way: zero columns, self-invalidating
class User < ApplicationRecord
has_secure_password
generates_token_for :password_reset, expires_in: 15.minutes do
password_salt&.last(10)
end
end
No migration. No cleanup job. No stored state. The token carries everything it needs and invalidates itself when the password changes.
The API at a glance
The feature lives in ActiveRecord::TokenFor and consists of three methods:
Method | Level | Description |
|---|---|---|
| Class macro | Declares a token purpose with optional expiry and embedded state |
| Instance | Produces a signed token string for the record |
| Class | Resolves a token back to a record, or returns |
There's also a bang variant find_by_token_for! that raises ActiveRecord::RecordNotFound for all failure cases: invalid signature, expired token, failed state comparison, or a missing record. Under the hood, it simply calls find_by_token_for and raises if the result is nil.
What a token looks like
If you're curious what generate_token_for actually produces, here's an example:
user.generate_token_for(:password_reset)
#=> "eyJfcmFpbHMiOnsiZGF0YSI6WzEsImtXNXdRMkpQIl0sInB1ciI6InBhc3N3b3JkX3Jlc2V0IiwiZXhwIjoiMjAyNC0wMS0xNVQxMDozMDowMFoifX0=--a1b2c3d4e5f6..."
It's a Base64-encoded, HMAC-signed string. The payload contains the record's primary key, the purpose, and the block's return value in plaintext JSON. The signature ensures that none of these values can be tampered with, but the contents are not encrypted. Anyone with the token can decode and read them. This is why you should never embed sensitive data in the block (more on this in the security section).
A complete password reset example
class User < ApplicationRecord
has_secure_password
generates_token_for :password_reset, expires_in: 15.minutes do
password_salt&.last(10)
end
end
That's the entire setup. No migration, no new column. Now you can use it:
user = User.find_by(email: "[email protected]")
# Generate and send
token = user.generate_token_for(:password_reset)
UserMailer.password_reset(user, token).deliver_later
# Later, when the user clicks the link
user = User.find_by_token_for(:password_reset, token)
if user
user.update!(password: "new-secure-password")
# The token is now automatically invalid. No cleanup needed.
else
# Token was expired, tampered with, or the password was already changed.
end
The critical detail: after the password changes, the password salt changes with it. The salt embedded in the token no longer matches the salt on the record, so find_by_token_for returns nil. The token has effectively "used itself up" without you writing a single line of invalidation logic.
A note on password_salt
You might wonder where password_salt comes from. It's a method provided by has_secure_password, which extracts the salt from the stored bcrypt password_digest. Under the hood, it does something equivalent to:
BCrypt::Password.new(password_digest).salt
We embed password_salt&.last(10) (the last 10 characters of the salt) rather than the full salt or the full digest. This gives us enough entropy for a reliable state comparison, while avoiding embedding the complete password salt in the token (which, remember, is readable plaintext inside the signed payload).
How it works under the hood
Understanding the internals helps you use the feature correctly and reason about its security properties.
Token structure
A token is a signed message (using Rails' MessageVerifier, which is backed by your app's secret_key_base) that contains a JSON payload with three components:
- The record's primary key, so Rails knows which record to look up.
- The purpose, so a
:password_resettoken can never be used as an:email_verificationtoken. - The block's return value, the embedded state that enables self-invalidation.
If you specified expires_in, the expiration timestamp is baked into the signature as well.
Verification flow
When you call find_by_token_for(:password_reset, token), Rails performs these steps in order:
- Signature verification. The
MessageVerifierchecks that the token hasn't been tampered with. If the signature is invalid, it returnsnilimmediately. - Purpose check. The purpose embedded in the token must match the purpose you're looking up. A token generated for
:email_verificationwill never resolve when queried with:password_reset. - Expiry check. If
expires_inwas set when the purpose was declared, and the token's timestamp plus that duration is in the past, it returnsnil. - Record lookup. Rails uses the embedded primary key to fetch the record from the database.
- State comparison. The block is evaluated again in the context of the freshly loaded record. Its return value is compared (as JSON) to the value stored in the token. If they differ, the token is treated as invalid.
Only when all five checks pass does the method return the record.
Comparison with signed_id
Before Rails 7.1, the standard approach for stateless tokens was signed_id, introduced in Rails 6.1. While signed_id provides tamper-proof tokens with optional expiration, it has a fundamental limitation: it cannot reflect record state.
A signed_id token for a user remains valid as long as it hasn't expired, even if the user has already used it to reset their password. To prevent reuse, you'd need to store the token (or a flag) in the database and check it manually. At that point, you've lost the "stateless" benefit entirely.
generates_token_for solves this by embedding mutable record state into the token itself. No database tracking needed.
Why this is secure
There are several properties that make this feature safe to expose to the outside world:
1. Cryptographic signing
Tokens are signed with HMAC using your application's secret_key_base. An attacker cannot forge a valid token without knowing the key. They cannot modify the payload (e.g., change the user ID) without invalidating the signature. This is the same signing mechanism that protects Rails sessions and cookies.
2. Purpose scoping
Each token is bound to a specific purpose. Even if an attacker intercepts a :email_verification token, they cannot use it to reset a password. The purpose is part of the signed payload and is verified on every lookup.
3. Time-based expiration
When you set expires_in, the absolute expiration timestamp is calculated at generation time (Time.now + expires_in) and baked into the token's signature. This means:
- An attacker cannot extend a token's lifetime by modifying it.
- Changing the
expires_invalue in your code only affects newly generated tokens. Previously generated tokens retain their original expiration timestamp and remain valid until that time passes (or the embedded state changes).
4. Automatic state-based invalidation
This is the feature's most important security property. By embedding record state (like a password salt) in the token, you get single-use semantics without any database bookkeeping. The moment the relevant attribute changes, all outstanding tokens for that purpose become invalid.
This protects against token reuse even within the expiration window.
5. No sensitive data in the token
An important caveat: the block's return value is embedded as human-readable plaintext JSON inside the signed token. The signing prevents tampering, but it does not encrypt the contents. Anyone who has the token can read the embedded value.
This is by design, and it means you should never embed secrets, full password hashes, or personal data in the block. Instead, embed a derivative value (like the last 10 characters of a password salt) that's sufficient for comparison but useless on its own.
6. secret_key_base rotation invalidates all tokens
Because every token is signed with your application's secret_key_base, rotating this key immediately invalidates all outstanding tokens across every purpose. This is worth knowing for two reasons:
- As a security tool: If you suspect token compromise at scale, rotating
secret_key_baseis an effective nuclear option. It also invalidates sessions and other signed data, so plan accordingly. - As an operational risk: An accidental key rotation (e.g., a misconfigured deployment) will break every in-flight password reset, email verification, and magic login link. Make sure your deployment pipeline treats
secret_key_baseas immutable infrastructure.
Practical patterns
Email verification (single-use)
class User < ApplicationRecord
generates_token_for :email_verification, expires_in: 24.hours do
end
end
This embeds the user's current email in the token. The token invalidates in two scenarios:
- The user changes their email before verifying. The embedded email no longer matches
emailon the record, so the old verification link stops working. This is the primary protection: it prevents someone from requesting verification for one email, changing to a different email, and then using the old link to "verify" the new address. - You want true single-use semantics. If you need the token to become invalid after verification (not just after an email change), embed a value that changes upon verification, such as an
email_verified_attimestamp:
class User < ApplicationRecord
generates_token_for :email_verification, expires_in: 24.hours do
email_verified_at.to_s
end
end
Now the token invalidates the moment you set email_verified_at, preventing reuse of the same link.
Magic login links
class User < ApplicationRecord
generates_token_for :magic_login, expires_in: 10.minutes do
updated_at.to_i
end
end
By embedding updated_at, the login link is effectively single-use: the moment the user signs in and you touch the record, the timestamp changes and the token is invalidated.
Important: Because
updated_atdoes not change on its own when a user logs in, you need to explicitly touch the record during the sign-in process, for example by callinguser.touchor updating a column likelast_login_at. Without this step, the token would remain valid for the full expiration window and could be reused.
Note: If you're using Devise, you could embed
current_sign_in_at.to_ifrom the Trackable module instead. For applications without Devise,updated_ator a dedicatedlast_login_atcolumn works just as well.
Account deletion confirmation
class User < ApplicationRecord
generates_token_for :account_deletion, expires_in: 1.hour
end
No block needed here. You just want a time-limited, tamper-proof token. If the user doesn't confirm within an hour, the token expires naturally.
Note: Without a block, there is no state-based invalidation, which means the token is technically reusable within the one-hour expiration window. In practice this is rarely a concern for account deletion: once the account is deleted on first use, the record no longer exists and the token resolves to
nilon any subsequent attempt. Still, it's worth being aware of this behavior if you adapt the pattern for other use cases.
Invitation tokens
class Invitation < ApplicationRecord
generates_token_for :accept, expires_in: 7.days do
accepted_at
end
end
When the invitation is created, accepted_at is nil. The moment the invitation is accepted and accepted_at is set to a timestamp, the embedded value no longer matches, preventing double-acceptance. This is cleaner than embedding accepted_at.nil?.to_s because any change to accepted_at, not just the nil to truthy transition, invalidates the token.
Testing token flows
Tokens involve time-based expiration and state-based invalidation, both of which are straightforward to test with the right tools.
Testing expiration with travel_to
class UserTest < ActiveSupport::TestCase
test "password reset token expires after 15 minutes" do
user = users(:alice)
token = user.generate_token_for(:password_reset)
# Token is valid immediately
assert_equal user, User.find_by_token_for(:password_reset, token)
# Token is expired after 16 minutes
travel 16.minutes do
assert_nil User.find_by_token_for(:password_reset, token)
end
end
end
Testing state-based invalidation
class UserTest < ActiveSupport::TestCase
test "password reset token is invalidated after password change" do
user = users(:alice)
token = user.generate_token_for(:password_reset)
# Token works before password change
assert_equal user, User.find_by_token_for(:password_reset, token)
# Change the password
user.update!(password: "new-secure-password")
# Same token is now invalid
assert_nil User.find_by_token_for(:password_reset, token)
end
end
Testing purpose isolation
class UserTest < ActiveSupport::TestCase
test "token for one purpose cannot be used for another" do
user = users(:alice)
token = user.generate_token_for(:password_reset)
assert_nil User.find_by_token_for(:email_verification, token)
end
end
These tests are fast (no HTTP requests, no mailer deliveries) and deterministic. They cover the three main failure modes: expiration, state change, and purpose mismatch.
When not to use it
generates_token_for is not the right tool for every token scenario:
- Long-lived API keys. These need to be stored, rotatable, and revocable on demand. Use
has_secure_tokenor a dedicated API key model. - OAuth tokens. These have their own standards and refresh mechanics.
- Tokens that need to be listed or audited. Since these tokens are stateless, you have no record of which tokens are outstanding. If you need an audit trail, store tokens in the database.
- High-frequency validation. Each
find_by_token_forcall hits the database to load the record and re-evaluate the block. For tokens validated on every request (like session tokens), consider alternatives.
Rate limiting on public endpoints
One more consideration: because find_by_token_for performs a database read on every call, public-facing endpoints that accept tokens (like password reset forms) should be protected with rate limiting. Without it, an attacker could brute-force tokens and generate excessive database load. In Rails 8, you can use the built-in rate_limit macro:
class PasswordResetsController < ApplicationController
rate_limit to: 10, within: 1.minute, only: :update
def update
user = User.find_by_token_for(:password_reset, params[:token])
# ...
end
end
For earlier Rails versions, use rack-attack or a similar middleware.
Summary
generates_token_for is a well-designed primitive that removes a common source of boilerplate and bugs from Rails applications. It gives you:
- Stateless tokens: no database columns, no cleanup jobs.
- Purpose binding: tokens can't be used for unintended actions.
- Time expiration: built into the signature, not just the payload.
- Self-invalidation: tokens that automatically expire when record state changes.
- Tamper resistance: HMAC signing backed by your
secret_key_base.
For password resets, email verification, magic links, and similar one-time-use flows, it's the right default choice in any Rails 7.1+ application.