ruby 88 lines · 3 tabs

Signed Password Reset Tokens with ActiveSupport::MessageVerifier in Rails

Shared by codesnips Jul 2026
3 tabs
class User < ApplicationRecord
  has_secure_password

  RESET_TOKEN_TTL = 30.minutes

  def self.reset_verifier
    ActiveSupport::MessageVerifier.new(
      Rails.application.secret_key_base,
      digest: "SHA256",
      serializer: JSON
    )
  end

  def generate_reset_token
    payload = { "id" => id, "digest" => password_digest }
    self.class.reset_verifier.generate(
      payload,
      purpose: :password_reset,
      expires_in: RESET_TOKEN_TTL
    )
  end

  def self.find_by_reset_token(token)
    payload = reset_verifier.verify(token, purpose: :password_reset)
    user = find_by(id: payload["id"])

    return nil unless user
    return nil unless user.password_digest == payload["digest"]

    user
  rescue ActiveSupport::MessageVerifier::InvalidSignature
    nil
  end
end
3 files · ruby Explain with highlit

Password resets need a token that is unguessable, tamper-proof, tied to a specific user, and expires on its own — without storing anything server-side. This snippet builds exactly that on top of ActiveSupport::MessageVerifier, which signs (but does not encrypt) a payload with an HMAC derived from the application's secret key base. The signature guarantees the payload came from the server and was not modified; the embedded expiry means an old link stops working even though nothing was written to the database.

In User model, the token logic lives behind a dedicated verifier that is scoped by purpose: :password_reset. Scoping matters: a token generated for password resets cannot be replayed against some other verifier that shares the same key base, because MessageVerifier#verify will reject a mismatched purpose. generate_reset_token signs a small hash containing the user id plus the current password_digest, and passes expires_in: so MessageVerifier embeds a signed expiry. The class method find_by_reset_token calls verify inside a rescue ActiveSupport::MessageVerifier::InvalidSignature block, returning nil for anything expired, forged, or malformed. The clever part is comparing the token's password_digest against the current one — once the user changes their password, the digest changes, so every previously issued reset link is invalidated automatically. This gives single-use behaviour for free, again with no extra columns.

In PasswordResetsController, create always renders the same confirmation regardless of whether the email exists, preventing account enumeration. edit resolves the token and bails to the login page if it is bad. update re-verifies the token, applies the new password, and relies on the digest change to burn the link. Because find_by_reset_token is called again in update, a token that expired between loading the form and submitting it is still caught.

In UserMailer, reset_password receives the raw token and builds the URL — the mailer never sees the user's secrets, only the opaque signed string. The main trade-off to remember is that signed tokens are readable: MessageVerifier does not hide the payload, so nothing sensitive beyond an id and a digest should go in it. For confidentiality one would reach for MessageEncryptor instead. This pattern is ideal when the goal is a stateless, self-expiring, self-invalidating link with minimal moving parts.


Related snips

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
class Post < ApplicationRecord
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy

  scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
  scope :draft, -> { where(published_at: nil) }

ActiveRecord scopes for reusable query logic

rails activerecord patterns
by Alex Kumar 1 tab
ruby
payload = {
  sub: user.id,
  iss: 'https://auth.example.com',
  aud: 'codesnips-api',
  exp: 15.minutes.from_now.to_i,
  iat: Time.now.to_i,

JWT issuance and verification without common footguns

jwt authentication api
by Kai Nakamura 2 tabs
ruby
timestamp = request.headers.fetch('X-Signature-Timestamp')
signature = request.headers.fetch('X-Signature')
payload = request.raw_post

data = "#{timestamp}.#{payload}"
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('WEBHOOK_SECRET'), data)

HMAC signed API requests for webhook and partner integrity

hmac api-signing webhooks
by Kai Nakamura 2 tabs
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
class PostsController < ApplicationController
  def index
    @posts = Post.includes(:author)
                 .order(created_at: :desc)
                 .page(params[:page])
                 .per(10)

Turbo Frames: infinite scroll with lazy-loading frame

rails turbo hotwire
by codesnips 4 tabs

Share this code

Here's the card — post it anywhere.

Signed Password Reset Tokens with ActiveSupport::MessageVerifier in Rails — share card
Link copied