Skip to content
yf_
← Writing

Rails signed_id is underrated

2 min read
  • 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_token column directly on the users table. 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, and email_verified_at columns directly to users. 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

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))

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.