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
class PasswordResetsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:email].to_s.downcase.strip)
if user
token = user.generate_reset_token
UserMailer.reset_password(user, token).deliver_later
end
# Same response whether or not the account exists (no enumeration).
redirect_to new_session_path, notice: "If that email exists, a reset link is on its way."
end
def edit
@user = User.find_by_reset_token(params[:token])
@token = params[:token]
return if @user
redirect_to new_password_reset_path, alert: "This reset link is invalid or has expired."
end
def update
@user = User.find_by_reset_token(params[:token])
unless @user
redirect_to new_password_reset_path, alert: "This reset link is invalid or has expired."
return
end
if @user.update(password_params)
redirect_to new_session_path, notice: "Password updated. Please sign in."
else
@token = params[:token]
render :edit, status: :unprocessable_entity
end
end
private
def password_params
params.require(:user).permit(:password, :password_confirmation)
end
end
class UserMailer < ApplicationMailer
def reset_password(user, token)
@user = user
@reset_url = edit_password_reset_url(token: token)
mail(to: user.email, subject: "Reset your password")
end
end
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
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
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
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
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
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
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
Share this code
Here's the card — post it anywhere.