Rails 8 の bin/rails generate authentication で認証を作ったあと、「パスワードを忘れた人向けのリセット機能、これ全部自分で書くんだっけ?」と身構えていませんか?
結論から言うと、パスワードリセットの一式はジェネレータが最初から生成してくれています。メール送信・トークン付きURL・新パスワード設定フォームまで揃っていて、追加の gem は要りません。
ただし、そのまま本番に出すと「リンクがすぐ期限切れになる」「そもそもメールが届かない・URLが作れずエラー」といった定番のつまずきが待っています。この記事では、生成されたコードを読み解きながら、仕組みとハマりどころを実務目線で押さえていきます。認証ジェネレータ自体の全体像は Rails 8の認証ジェネレータとDeviseはどっちを使う? でまとめているので、そちらとあわせてどうぞ。
まず完成形:パスワードリセットの流れ
コードに入る前に、ユーザーから見た一連の流れを掴んでおきましょう。パスワードリセットは、ざっくり次の4ステップで動きます。

ポイントは、3番目の「トークン付きURL」が有効期限つきだということです。この期限が、あとで出てくる最大のハマりどころになります。まずはこの流れを頭に入れたまま、Rails が何を用意してくれているかを見ていきます。
認証ジェネレータが生成するもの(パスワードリセット一式)
bin/rails generate authentication を実行すると、ログインまわりに加えてパスワードリセット用のファイルが一式生成されます。リセットに関係するのは次のあたりです。
| 生成物 | 役割 |
|---|---|
app/controllers/passwords_controller.rb | リセット申請(create)と再設定(update)の処理 |
app/mailers/passwords_mailer.rb | リセット用メールを送るメーラー |
app/views/passwords/new, edit | メールアドレス入力フォーム/新パスワード入力フォーム |
app/views/passwords_mailer/reset | リセットメールの本文(リンクを含む) |
意外に思うかもしれませんが、生成される User モデルはとてもシンプルです。パスワードリセットのための特別なカラムやメソッドは、モデルには書かれていません。
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
normalizes :email_address, with: ->(e) { e.strip.downcase }
endリセットの肝は、この has_secure_password の中に隠れています。次の章でそこを開けてみましょう。
仕組みの核心:リセットトークンはDBに保存されない
「パスワードリセットのトークンを保存するカラムが無いのに、どうやって検証するの?」——ここがRailsらしくてよくできているところです。Rails 7.1 以降の has_secure_password は、内部で次のようなトークン定義を自動的に持っています。
# has_secure_password が内部で定義している(イメージ)
generates_token_for :password_reset, expires_in: 15.minutes do
password_salt&.last(10)
endこの generates_token_for が作るトークンは、署名付きの文字列で、DBには一切保存されません。ユーザーIDと有効期限を署名で包んだ値なので、受け取ったトークンを検証するだけで「誰のリセットか」「期限内か」が分かります。リセット用のカラムやマイグレーションが要らないのはこのためです。
さらに巧妙なのが、ブロックの中で password_salt(パスワードハッシュのソルト)の一部を混ぜている点です。パスワードを変更するとソルトも変わるため、一度リセットに成功すると、同じトークンは自動的に使えなくなります。「1回限りのリンク」が、わざわざフラグ管理をしなくても実現できているわけです。

生成されたコードを読む(create / update)
では PasswordsController の中身を見ます。長くないので、全体像を掴んでしまいましょう。
class PasswordsController < ApplicationController
allow_unauthenticated_access
before_action :set_user_by_token, only: %i[ edit update ]
def new
end
def create
if user = User.find_by(email_address: params[:email_address])
PasswordsMailer.reset(user).deliver_later
end
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
end
def edit
end
def update
if @user.update(params.permit(:password, :password_confirmation))
redirect_to new_session_path, notice: "Your password has been reset."
else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end
end
private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end
end読みどころは3つです。順番に見ていきます。
① create(リセット申請):メールアドレスでユーザーを探し、見つかればメールを送ります。注目したいのは、ユーザーが見つかっても見つからなくても、同じメッセージで同じ画面にリダイレクトしていることです。これは「そのメールアドレスは登録されているか?」を外部から探れないようにする、ユーザー列挙対策です。
② set_user_by_token(トークン検証):find_by_password_reset_token! が、先ほどの署名付きトークンを検証します。期限切れでも改ざんでも、まとめて InvalidSignature 例外になるので、「無効または期限切れ」の1メッセージでまとめて弾けます。
③ update(再設定):@user.update でパスワードを更新するだけ。has_secure_password が password_confirmation の一致チェックまでやってくれるので、不一致なら false が返り、編集画面に戻します。更新に成功した瞬間、前章のソルトの仕組みでリンクが失効します。
ちなみに最近の Rails では、create に rate_limit to: 10, within: 3.minutes, only: :create のような連打対策も生成されます。リセットメールの乱発を防ぐためのものです。
ハマりどころ①:トークンが15分で切れる
ここからが本題です。デフォルトのトークン有効期限は 15分。開発中はすぐにリンクを踏むので気づきませんが、本番では話が変わります。
SMTPの経路が混んでいたり、受信側で迷惑メール判定の精査が入ったりすると、メールが届くまで数分〜十数分かかることがあります。ユーザーがメールに気づいてリンクを踏む頃には、もう期限切れ——「リンクが無効です」の無限ループ、という問い合わせが地味に増えます。
期限を延ばすのは簡単で、has_secure_password に reset_token: オプションを渡すだけです。モデルを1行変えます。
class User < ApplicationRecord
# 有効期限を30分に延ばす
has_secure_password reset_token: { expires_in: 30.minutes }
has_many :sessions, dependent: :destroy
normalizes :email_address, with: ->(e) { e.strip.downcase }
end延ばしすぎは禁物です。リセットリンクは「パスワードを変更できる券」そのものなので、長くするほど漏れたときのリスク時間が伸びます。30分〜1時間程度に収めるのが現実的な落としどころです。メール本文には distance_of_time_in_words(0, @user.password_reset_token_expires_in) で「このリンクは◯分で期限切れになります」と出せるので、延ばした値をユーザーにも伝えておくと親切です。
ハマりどころ②:本番でメールのリンクが作れない・届かない
もう1つの定番が、メールまわりの設定漏れです。リセットメールのテンプレートは、リンクを edit_password_url(@user.password_reset_token) のように _url ヘルパーで組み立てています。
_path ではなく _url なので、ホスト名(https://example.com)が分からないとURLを生成できず、例外になります。これを教えるのが default_url_options です。本番環境に次を設定します。
# config/environments/production.rb
config.action_mailer.default_url_options = { host: "example.com", protocol: "https" }
# 実際に送るための配送設定(例:SMTP)
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: "smtp.example.com",
port: 587,
user_name: Rails.application.credentials.dig(:smtp, :user_name),
password: Rails.application.credentials.dig(:smtp, :password),
authentication: :plain
}開発環境でも default_url_options は必要です。あわせて、実際にメールサーバーを立てなくても中身を確認できるようにしておくと楽です。
# config/environments/development.rb
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }送信メールの中身は http://localhost:3000/rails/mailers のメーラープレビューで確認できます。リンクのホストやトークンが正しく埋まっているか、ここで一度目視しておくと本番での事故が減ります。SMTPの認証情報を credentials で管理する方法は別記事にする予定なので、環境変数とどちらで持つか迷ったら認証ジェネレータの記事の考え方も参考にしてください。
セキュリティで押さえておきたい3点
最後に、パスワードリセットで事故らないための勘どころを整理します。生成コードは、実はこの多くを最初から面倒見てくれています。
- ユーザー列挙を防ぐ:
createはメールの存在有無にかかわらず同じ応答を返します。ここに「そのメールは登録されていません」を足したくなりますが、足さないのが正解です。 - トークンは1回限り・期限つき:署名付きトークン+ソルト混ぜ込みで、再設定が済めば自動失効します。独自にフラグを足す必要はありません。
- トークンがURLに乗る点に注意:リセットリンクにはトークンが含まれます。そのページに外部ドメインの画像やスクリプトを置くと、
Refererヘッダー経由でトークンが漏れる可能性があります。リセット画面は余計な外部リソースを読み込ませないのが安全です。
まとめ
Rails 8 のパスワードリセットは、「自分で全部書く」ものではなく「生成されたものを理解して、2か所だけ整える」作業でした。最後におさらいです。
- リセット一式はジェネレータが生成済み。トークンは
has_secure_password由来でDBに保存されない署名付き - デフォルト期限は15分。遅いメールで切れるなら
has_secure_password reset_token: { expires_in: 30.minutes }で調整 - 本番は
default_url_optionsのホスト設定が必須。設定漏れだとURL生成で落ちる - ユーザー列挙対策・1回限りトークンは標準で効いている。余計な「親切」を足さない
土台ができたら、「そもそもパスワードを持たせない」という発展もあります。興味があれば マジックリンク(パスワードレス)ログインに改造する方法 もどうぞ。
あわせて読みたい
- Rails 8の認証ジェネレータとDeviseはどっちを使う?違い・生成コード・移行の判断基準まで解説
- Rails 8の認証ジェネレータをマジックリンク(パスワードレス)ログインに改造する方法
- Deviseでプロフィール更新とパスワード変更フォームを分ける方法【Rails 8対応】
スポンサーリンク
