Rails signed_id is underrated
- Ruby on Rails
- Security
- Architecture
Email verification and password resets are features every web app builds eventually. Over the years, I’ve seen dozens of custom implementations. They range from terrifyingly insecure to military-grade overengineered.
I’ve seen the full spectrum of custom token schemes:
- The un-expiring column. Storing a
reset_tokencolumn directly on theuserstable. The token gets set once on signup and never expires or resets after use, allowing infinite re-use. - The DB clutter pattern. Adding
password_reset_token,password_reset_sent_at,email_verification_token, andemail_verified_atcolumns directly tousers. The schema grows with every new token requirement. - The JWT machinery. Spinning up a JSON Web Token generator, managing secret keys separately, and writing custom verification middleware just to verify an email address.
Most of these approaches either leak state into the database or introduce unnecessary dependencies.
The Rails solution: signed_id
Rails added signed_id in version 6.1. It uses ActiveSupport::MessageVerifier under the hood to generate a tamper-proof, signed token containing the record’s ID.
No extra database columns, no custom secret management, and no external gems.
Generating a token is a single call:
user = User.find(123)
token = user.signed_id(purpose: :email_verification, expires_in: 24.hours)
Locating the user from the token:
user = User.find_signed(token, purpose: :email_verification)
If the token is tampered with, expired, or evaluated against the wrong purpose, find_signed returns nil.
Where it shines
1. Email verification
Instead of creating verification tokens in the database, the controller generates a signed ID scoped to the purpose:
# Generating the link
url = verify_email_url(token: user.signed_id(purpose: :email_verification, expires_in: 24.hours))
# Verifying the link
def show
user = User.find_signed(params[:token], purpose: :email_verification)
if user&.update(email_verified: true)
redirect_to root_path, notice: "Email verified."
else
redirect_to new_verification_path, alert: "Invalid or expired link."
end
end
2. Password resets
Password reset links require tight expiry windows and single-purpose isolation:
# Generating the reset token
token = user.signed_id(purpose: :password_reset, expires_in: 15.minutes)
# Processing the reset
def update
user = User.find_signed(params[:token], purpose: :password_reset)
if user&.update(password_params)
redirect_to login_path, notice: "Password updated."
else
render :edit, status: :unprocessable_entity
end
end
3. One-click unsubscribe links
Marketing emails shouldn’t force users to log in just to change notification settings. A signed ID embeds identity safely:
unsubscribe_url = unsubscribe_url(token: user.signed_id(purpose: :unsubscribe, expires_in: 30.days))
4. Temporary download links
Securing private attachments without auth headers or session cookies:
attachment = Document.find(params[:id])
download_url = document_download_url(token: attachment.signed_id(purpose: :download, expires_in: 5.minutes))
Why it’s rarely used
I think signed_id sits unused in most codebases simply because it didn’t get enough exposure when Rails 6.1 shipped. It’s tucked away in ActiveRecord documentation, so developers keep reaching for custom token tables or JWT machinery.
I believe it didn’t get the publicity it deserves for a feature that handles signing, expiration, and purpose-scoping out of the box. That’s why I’m writing this article: it solves a problem almost every Rails app has without adding schema clutter or external gems.