Stop Storing Reset Tokens in Your Database: Using Rails generates_token_for
Stop cluttering your database with reset_token columns. Learn how Rails 7.1's generates_token_for creates signed, self-invalidating tokens for password resets and magic links.
29 Aug 2025, 20:29 UTC

The Problem with Opaque Tokens
Most Rails developers handle password resets or email verifications by adding a reset_token column to the users table. The workflow is predictable: generate a random string, save it to the database, email it to the user, and then delete it once used. This approach creates unnecessary database writes and requires you to manage expiration timestamps and cleanup tasks for expired tokens.
Rails 7.1 introduced generates_token_for, a built-in mechanism that allows you to create signed, time-limited tokens that are verifiable without requiring a dedicated database column. The key takeaway is that the token carries its own validity logic, meaning the database only stores the state of the user, not the state of the token.
How Signed Tokens Work
Unlike a random string (an opaque token), generates_token_for creates a signed token using ActiveSupport::MessageVerifier. It bundles the record's primary key and a specific piece of data—defined by you in a block—into a URL-safe Base64 string. This string is signed using your application's secret_key_base, making it impossible for a user to tamper with the token without invalidating the signature.
The magic happens during verification. When you call find_by_token_for, Rails doesn't just check the signature; it re-evaluates the block you defined. If the value returned by the block today differs from the value that was embedded in the token when it was created, the token is considered invalid.
Implementing a Password Reset Flow
To implement this, you define the token purpose in your model. For a password reset, you want the token to expire immediately if the user changes their password. By using the password_salt (provided by has_secure_password), you ensure that any change to the password rotates the salt, which in turn invalidates all outstanding reset tokens.
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
# The block defines the 'version' of the token.
# We use the last 10 chars of the salt to keep the token length manageable.
generates_token_for :password_reset, expires_in: 2.hours do
password_salt&.last(10)
end
end
Generating and Verifying the Token
You can now generate the token in your controller or a background job and verify it in a dedicated reset controller. Run these commands in your Rails console (bin/rails c) to verify the behavior:
# 1. Create a user
u = User.create!(email: '[contact removed]', password: 'old-password')
# 2. Generate the token
token = u.generate_token_for(:password_reset)
# => "T3B6... (long base64 string)"
# 3. Verify the token (Success)
User.find_by_token_for(:password_reset, token)
# => #
# 4. Change the password (This changes the password_salt)
u.update!(password: 'new-secure-password')
# 5. Verify the same token again (Failure)
User.find_by_token_for(:password_reset, token)
# => nil
Critical Engineering Trade-offs
While this removes the need for token tables, it introduces a different set of constraints:
- Statelessness: Because the token is not in your database, you cannot "revoke" a specific token via an admin panel unless you change the value returned by the block (e.g., by updating the user's password or a
token_versioncolumn). - Payload Size: The token length grows based on what the block returns. Avoid returning large strings or PII (Personally Identifiable Information) in the block, as the token is signed but not encrypted.
- Deterministic Blocks: The block must be side-effect free. Never use
Time.currentorrandinside thegenerates_token_forblock, as the value must be identical during both generation and verification.
Verification and Risks
To verify this is working in your environment, check your Rails version (rails -v) to ensure you are on 7.1.0 or higher. If you rotate your secret_key_base, be aware that all outstanding tokens for all purposes will be immediately invalidated.
To test the expiration, you can set expires_in: 1.second in your model, generate a token, sleep for two seconds, and confirm that find_by_token_for returns nil.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.