Rails 8 の bin/rails generate authentication で認証を作ったあと、「パスワードを持たせるのやめたいな」と思ったことはありませんか?
パスワードは、持たせた瞬間に運用コストが発生します。ハッシュ化して保存し、リセットメールを送り、使い回しや流出に怯える。小さなチームだと、そこに割く体力がもったいないですよね。
そこで選択肢に上がるのがマジックリンク(パスワードレス)ログインです。パスワードという概念そのものを、アプリから消してしまう方式です。
この記事では、Rails 8 の認証ジェネレータで作った土台をマジックリンク方式に作り替える手順を、コード全部込みで解説します。実装の参考にするのは、37signals(Basecamp)が公開しているカンバンアプリ Fizzy の実際のソースコードです。「本番で動いているアプリがどう作っているか」をそのまま読み解いていきます。
マジックリンク方式とは?パスワード認証・メール認証との違い
実装に入る前に、そもそもマジックリンクが何なのかを整理しておきます。
マジックリンク認証は、「メールを受け取れること」そのものを本人確認の手段にする方式です。ログイン画面でメールアドレスを入力すると、使い捨ての認証情報(リンクまたはコード)がメールで届く。それを使ってログインする、という流れになります。
パスワードを保存しないので、「覚えてもらう」「リセットする」「流出に備える」という一連の仕事がまるごと消えます。
「メール認証」とは役割が別物です
名前が似ているので混同されがちですが、新規登録時のいわゆるメール認証(メールアドレス確認)とは目的が違います。
| メール認証(アドレス確認) | マジックリンク認証 | |
|---|---|---|
| いつ使う | 新規登録のときだけ | ログインのたびに毎回 |
| 目的 | そのアドレスが実在して本人のものか確かめる | 本人かどうか確かめてログインさせる |
| 終わったあと | verified_at などを立てて完了。以降は使わない | セッションを発行してログイン状態にする |
| パスワード | 別途必要 | 不要(これ自体が認証手段) |
どちらも「メールを受け取れる人=本人とみなす」という前提は同じです。違うのは、1回きりの確認で役目を終えるのがメール認証、毎回のログイン手段として使い続けるのがマジックリンクという点です。
登録時のメール認証を実装したことがあるなら、あの仕組みを毎回のログインに転用するイメージが近いと思います。
メリットとデメリット
採用を判断するうえで、良い面と困る面を先に並べておきます。
| メリット | デメリット |
|---|---|
| パスワードを保存しない。DBが漏れてもパスワードは漏れず、リセット機能ごと不要になる | ログインのたびにメールを開く一手間。ただしログイン状態を維持すれば頻度は下げられる(後述) |
| ユーザーが覚えるものがゼロ。「パスワードを忘れた」からの離脱が無くなる | メールの遅延・迷惑メール判定でログインできなくなる。送信基盤の信頼性がそのままログインの可用性になる |
| 実質的に2要素に近い。ログインにメールボックスへのアクセスが必要になる | メールボックスを乗っ取られると突破される(ただしパスワードリセットも同じ弱点を持つ) |
デメリットの2つ目は特に見落としがちです。パスワード認証なら「メールが届かない」はリセットのときだけの問題ですが、マジックリンクではメールが止まった瞬間に誰もログインできなくなります。SendGrid や SES など、送信基盤の監視はセットで考えてください。
1つ目のデメリットについては、ログイン状態をどれだけ維持できるか次第で印象がかなり変わります。Rails 8のジェネレータが実際に何日ログインを保つのかは、記事の後半で数字を出して整理します。
送るのは「リンク」と「コード」の2種類
もう一つ押さえておきたいのが、メールで送るものには2種類あることです。
- リンク方式:クリックするとログインできるURLを送る
- コード方式:6文字程度のワンタイムコードを送り、画面に入力してもらう
名前は「マジックリンク」ですが、実際にはコード方式を採っているサービスが多く、この記事でもコード方式で実装します。理由は後半の「リンク方式とコード方式、どちらを送るか」で詳しく説明します。
まず完成形:パスワードレスログインの流れ
細かい実装に入る前に、これから作るものの全体像を押さえておきましょう。ログインは3ステップになります。
| ステップ | ユーザーの操作 | サーバー側でやること |
|---|---|---|
| 1 | メールアドレスを入力 | 6文字のワンタイムコードを発行してメール送信。「このブラウザはこのメアドで認証待ち」という署名付きCookieを発行 |
| 2 | メールに届いたコードを入力 | コードを検索 → 見つかったら即座に削除(使い捨て) |
| 3 | — | Cookieのメアドとコードの持ち主が一致するか照合してからログイン |

ポイントは3つ目です。「コードが合っていればログイン」ではなく、「コードが合っている、かつ、そのコードを要求したブラウザである」ところまで確認します。ここを省くと、他人のコードを盗んだだけでログインできてしまいます。
Rails 8 の認証ジェネレータが生成する Session モデルや start_new_session_for はそのまま使えます。差し替えるのは「本人確認の方法」だけで、セッション管理の仕組みには手を入れません。
STEP1:Rails 8の認証ジェネレータで土台を作る
まずは普通に認証を生成します。
bin/rails generate authentication
bin/rails db:migrateこれで User / Session / Current モデル、SessionsController、PasswordsController、そして認証の心臓部である Authentication concern が生成されます。生成物の詳細やDeviseとの使い分けはRails 8の認証ジェネレータとDeviseはどっちを使う?にまとめているので、そちらもどうぞ。
今回改造対象になるのは、この Authentication concern が持っている次のメソッドたちです。中身を思い出しておきましょう。
# app/controllers/concerns/authentication.rb(生成されたもの・抜粋)
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
end
end
def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
end「ログインさせる処理」は start_new_session_for(user) がすでに面倒を見てくれています。私たちがこれから書くのは、この行にたどり着くまでの本人確認だけです。ここが分かっていると、以降のコードがぐっと読みやすくなります。
STEP2:Fizzyの実装を読む(本家はどう作っているか)
自分で書き始める前に、実際に運用されているコードを読んでおきましょう。Fizzy のマジックリンク関連は、ファイルが綺麗に分かれています。
| ファイル | 役割 |
|---|---|
app/models/magic_link.rb | コードの発行・有効期限・使い捨て |
app/models/magic_link/code.rb | コード文字列の生成と正規化 |
app/mailers/magic_link_mailer.rb | コードをメールで送る |
app/controllers/concerns/authentication/via_magic_link.rb | 認証待ち状態をCookieで持ち回る |
app/controllers/sessions_controller.rb | メアド受付 → コード発行 |
app/controllers/sessions/magic_links_controller.rb | コード検証 → ログイン |
※ Fizzy は複数アカウントに所属できるマルチテナント構成のため、ユーザーを表すモデルが Identity という名前になっています。以下、読み替えやすいように Identity = Rails 8 ジェネレータでいう User だと思って読んでください。
MagicLink モデル:6文字コード+15分+使い捨て
まず本体です。驚くほど短いので、そのまま引用します。
# Fizzy: app/models/magic_link.rb
class MagicLink < ApplicationRecord
CODE_LENGTH = 6
EXPIRATION_TIME = 15.minutes
belongs_to :identity
enum :purpose, %w[ sign_in sign_up ], prefix: :for, default: :sign_in
scope :active, -> { where(expires_at: Time.current...) }
scope :stale, -> { where(expires_at: ..Time.current) }
before_validation :generate_code, on: :create
before_validation :set_expiration, on: :create
validates :code, uniqueness: true, presence: true
class << self
def consume(code)
active.find_by(code: Code.sanitize(code))&.consume
end
def cleanup
stale.delete_all
end
end
def consume
destroy
self
end
end設計の意図が読み取れるポイントを挙げると、こうなります。
- コードは6文字・有効期限は15分。定数なので変えたければ1行で変えられる
consumeはdestroyしてから自分自身を返す。つまり1回使ったら消える(リプレイ攻撃対策)- 検索は
activeスコープ経由。期限切れはfind_byの時点でヒットしない - 期限切れは消さずに残る。
cleanupを定期実行して掃除する分業になっている
scope :active, -> { where(expires_at: Time.current...) } の Time.current... という書き方は、終端を省略した Range です。「現在時刻より後」という条件を、SQLを意識せずに書けます。
MagicLink::Code:人間が読み間違えても通る「寛容な」コード
個人的に一番面白いのがこのファイルです。ユーザーがコードを打ち間違える前提で作られています。
# Fizzy: app/models/magic_link/code.rb
module MagicLink::Code
CODE_SUBSTITUTIONS = { "O" => "0", "I" => "1", "L" => "1" }.freeze
class << self
def generate(length)
SecureRandom.base32(length)
end
def sanitize(code)
if code.present?
normalize_code(code)
.then { apply_substitutions(it) }
.then { remove_invalid_characters(it) }
end
end
private
def normalize_code(code)
code.to_s.upcase
end
def apply_substitutions(code)
CODE_SUBSTITUTIONS.reduce(code) { |result, (from, to)| result.gsub(from, to) }
end
def remove_invalid_characters(code)
code.gsub(/[^#{SecureRandom::BASE32_ALPHABET.join}]/, "")
end
end
endSecureRandom.base32 が使う文字セットは Crockford Base32、つまり 0-9 と A-Z から I / L / O / U の4文字を除いたものです。1 と I、0 と O のような見間違えやすい文字を最初から発行しないという発想ですね。
そのうえで sanitize が、ユーザーの入力を寛大に受け止めます。小文字は大文字に、O と打たれたら 0 に、I や L は 1 に読み替え、ハイフンや空白は捨てる。
実際、Fizzy のテストには MagicLink::Code.sanitize("OIL123") が "011123" になる、というアサーションが書かれています。メールからコピペしたときの余計な空白でログインに失敗する、といったストレスをここで吸収しているわけです。
pending_authentication_token:ここが一番の肝
マジックリンクを自作するとき、多くの人が抜かしてしまうのがこの部分です。
「メールアドレスを入力した」という状態は、次のリクエストまで覚えておく必要があります。かといって、生のメールアドレスを普通の session に置くだけでは、有効期限の管理もできません。Fizzy は期限付きの署名済みトークンをCookieに入れて持ち回っています。
# Fizzy: app/controllers/concerns/authentication/via_magic_link.rb(抜粋)
def set_pending_authentication_token(magic_link)
cookies[:pending_authentication_token] = {
value: pending_authentication_token_verifier.generate(magic_link.identity.email_address, expires_at: magic_link.expires_at),
httponly: true,
same_site: :lax,
expires: magic_link.expires_at
}
end
def email_address_pending_authentication
pending_authentication_token_verifier.verified(pending_authentication_token)
end
def pending_authentication_token_verifier
Rails.application.message_verifier(:pending_authentication)
endRails.application.message_verifier は、Rails が標準で持っている署名機能です。generate で署名し、verified で検証する。改ざんされていたり期限切れだったりすれば verified は nil を返してくれるので、こちら側で例外処理を書く必要もありません。
この仕組みがあることで「コードを発行したブラウザ」と「コードを入力したブラウザ」が同一かどうかを判定できます。マジックリンクの安全性を支えているのは、実はコードそのものよりこのCookieのほうです。
検証側:コードとメールアドレスの両方を照合する
そして検証。MagicLink.consume でコードを引き当てたあと、もう一段の照合が入っているのが分かります。
# Fizzy: app/controllers/sessions/magic_links_controller.rb(抜粋)
def create
if magic_link = MagicLink.consume(code)
authenticate magic_link
else
invalid_code
end
end
def authenticate(magic_link)
if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || "", magic_link.identity.email_address)
sign_in magic_link
else
email_address_mismatch
end
endCookieが指すメールアドレスと、コードの持ち主のメールアドレスが一致して初めてログインです。比較に ActiveSupport::SecurityUtils.secure_compare を使っているのは、実行時間の差から情報が漏れるタイミング攻撃を避けるためですね。
Fizzy のテストには、この挙動を守るための "create with cross-user code" というケースが実際に用意されています。Aさんがメアドを入力した状態でBさんのコードを入力しても弾かれる、というテストです。マジックリンクを自作するなら、このテストは真っ先に書く価値があります。
STEP3:自分のRails 8アプリに実装する
ここからは、読み解いた設計を bin/rails generate authentication の生成物の上に載せていきます。Identity を User に読み替えた、そのまま動く構成です。
作業前に、これから触るファイルを一覧にしておきます。新しく作るのは6ファイル、既存の生成物に手を入れるのは3ファイルだけです。
| ファイル | 新規/変更 | やること |
|---|---|---|
db/migrate/..._create_magic_links.rb | 新規 | コードと有効期限を持つテーブル |
app/models/magic_link.rb | 新規 | 発行・期限・使い捨て |
app/models/magic_link/code.rb | 新規 | コード文字列の生成と正規化 |
app/mailers/magic_link_mailer.rb | 新規 | コードをメールで送る |
app/controllers/concerns/authentication/via_magic_link.rb | 新規 | 認証待ち状態をCookieで持ち回る |
app/controllers/sessions/magic_links_controller.rb | 新規 | コード検証 → ログイン |
app/models/user.rb | 変更 | send_magic_link を追加 |
app/controllers/concerns/authentication.rb | 変更 | concern を1行 include |
app/controllers/sessions_controller.rb | 変更 | パスワード照合をコード送信に置き換え |
1. MagicLink モデルとマイグレーション
bin/rails generate model MagicLink user:references code:string:uniq expires_at:datetime生成されたマイグレーションに null: false と有効期限のインデックスを足します。cleanup が期限で絞り込むので、このインデックスは効いてきます。
class CreateMagicLinks < ActiveRecord::Migration[8.0]
def change
create_table :magic_links do |t|
t.references :user, null: false, foreign_key: true
t.string :code, null: false
t.datetime :expires_at, null: false
t.timestamps
end
add_index :magic_links, :code, unique: true
add_index :magic_links, :expires_at
end
endモデル本体は Fizzy とほぼ同じです。purpose(サインインかサインアップか)は今回のログイン実装には不要なので落としています。
# app/models/magic_link.rb
class MagicLink < ApplicationRecord
CODE_LENGTH = 6
EXPIRATION_TIME = 15.minutes
belongs_to :user
scope :active, -> { where(expires_at: Time.current...) }
scope :stale, -> { where(expires_at: ..Time.current) }
before_validation :generate_code, on: :create
before_validation :set_expiration, on: :create
validates :code, presence: true, uniqueness: true
class << self
# コードを引き当てて、その場で使い捨てる
def consume(code)
active.find_by(code: Code.sanitize(code))&.consume
end
# 期限切れの掃除(定期実行する)
def cleanup
stale.delete_all
end
end
def consume
destroy
self
end
private
def generate_code
self.code ||= loop do
candidate = Code.generate(CODE_LENGTH)
break candidate unless self.class.exists?(code: candidate)
end
end
def set_expiration
self.expires_at ||= EXPIRATION_TIME.from_now
end
end2. コード生成モジュール
ここは Fizzy から少しだけ変えます。理由は後述しますが、SecureRandom.base32 は Rails 8.0 / 8.1 には存在しません。どのバージョンでも動くように、文字セットと生成処理を自前で持たせます。
# app/models/magic_link/code.rb
module MagicLink::Code
# Crockford Base32: 見間違えやすい I / L / O / U を除外した文字セット
ALPHABET = (("0".."9").to_a + ("A".."Z").to_a - %w[ I L O U ]).freeze
# ユーザーが打ち間違えやすい文字の読み替え
SUBSTITUTIONS = { "O" => "0", "I" => "1", "L" => "1" }.freeze
class << self
def generate(length)
Array.new(length) { ALPHABET[SecureRandom.random_number(ALPHABET.size)] }.join
end
def sanitize(code)
if code.present?
normalize(code)
.then { |value| substitute(value) }
.then { |value| remove_invalid_characters(value) }
end
end
private
def normalize(code)
code.to_s.upcase
end
def substitute(code)
SUBSTITUTIONS.reduce(code) { |result, (from, to)| result.gsub(from, to) }
end
def remove_invalid_characters(code)
code.gsub(/[^#{ALPHABET.join}]/, "")
end
end
end乱数には必ず SecureRandom を使ってください。rand や Array#sample の既定の乱数は予測可能なので、認証コードには使えません。
3. Userモデルとメーラー
ジェネレータが作った User に、マジックリンクの発行メソッドを足します。has_secure_password と normalizes :email_address は生成時点で入っているので、追加するのは3行だけです。
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
has_many :magic_links, dependent: :destroy # 追加
normalizes :email_address, with: ->(e) { e.strip.downcase }
# 追加:コードを発行してメールで送る
def send_magic_link
magic_links.create!.tap do |magic_link|
MagicLinkMailer.sign_in_instructions(magic_link).deliver_later
end
end
endメーラーは件名にコードを入れておくのがおすすめです。メールを開かなくても通知だけでコードが読めるので、体感がかなり良くなります(Fizzy も件名にコードを入れています)。
# app/mailers/magic_link_mailer.rb
class MagicLinkMailer < ApplicationMailer
def sign_in_instructions(magic_link)
@magic_link = magic_link
mail to: magic_link.user.email_address,
subject: "ログイン用コード: #{magic_link.code}"
end
end<%# app/views/magic_link_mailer/sign_in_instructions.text.erb %>
ログインページに、次の6文字のコードを入力してください。
<%= @magic_link.code %>
このコードは <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %> 有効です。
心当たりがない場合は、このメールは破棄してください。4. Authentication concern に認証待ち状態を足す
生成された Authentication concern は触らず、別ファイルの concern として足して include するのがおすすめです。Rails 本体がジェネレータを更新したときに差分を追いやすくなります。
# app/controllers/concerns/authentication/via_magic_link.rb
module Authentication::ViaMagicLink
extend ActiveSupport::Concern
private
# コードを発行したら、コード入力画面へ送る
def redirect_to_session_magic_link(magic_link)
set_pending_authentication_token(magic_link)
redirect_to session_magic_link_url
end
# 存在しないメアド用のダミー(ユーザー列挙対策・後述)
def redirect_to_fake_session_magic_link(email_address)
fake_magic_link = MagicLink.new(
user: User.new(email_address: email_address),
code: MagicLink::Code.generate(MagicLink::CODE_LENGTH),
expires_at: MagicLink::EXPIRATION_TIME.from_now
)
redirect_to_session_magic_link fake_magic_link
end
def set_pending_authentication_token(magic_link)
cookies[:pending_authentication_token] = {
value: pending_authentication_token_verifier.generate(
magic_link.user.email_address, expires_at: magic_link.expires_at
),
httponly: true,
same_site: :lax,
expires: magic_link.expires_at
}
end
def email_address_pending_authentication
pending_authentication_token_verifier.verified(cookies[:pending_authentication_token])
end
def clear_pending_authentication_token
cookies.delete(:pending_authentication_token)
end
def pending_authentication_token_verifier
Rails.application.message_verifier(:pending_authentication)
end
end生成済みの Authentication concern には、include とヘルパー登録の2行だけ足します。
# app/controllers/concerns/authentication.rb
module Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :authenticated?
helper_method :email_address_pending_authentication # 追加
include Authentication::ViaMagicLink # 追加
end
# 以下、生成されたまま
end5. SessionsController をパスワードレスに書き換える
ここが一番の変更点です。生成された create は User.authenticate_by(email_address:, password:) でパスワード照合していますが、それをまるごと「コードを発行してメールを送る」に置き換えます。
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create,
with: -> { redirect_to new_session_url, alert: "しばらく時間をおいてからお試しください。" }
def new
end
def create
if user = User.find_by(email_address: email_address)
redirect_to_session_magic_link user.send_magic_link
else
# 存在しないメアドでも、見た目は全く同じ画面へ
redirect_to_fake_session_magic_link email_address
end
end
def destroy
terminate_session
redirect_to new_session_path
end
private
def email_address
params.expect(:email_address)
end
endそしてコード検証側のコントローラを新しく作ります。MagicLink.consume と secure_compare の二段構えは、Fizzy の作りをそのまま踏襲しています。
# app/controllers/sessions/magic_links_controller.rb
class Sessions::MagicLinksController < ApplicationController
allow_unauthenticated_access
rate_limit to: 10, within: 15.minutes, only: :create,
with: -> { redirect_to session_magic_link_url, alert: "15分ほど時間をおいてからお試しください。" }
before_action :ensure_email_address_pending_authentication
def show
end
def create
if magic_link = MagicLink.consume(params.expect(:code))
authenticate magic_link
else
redirect_to session_magic_link_url, alert: "コードが違うようです。もう一度ご確認ください。"
end
end
private
# メアド入力を飛ばして直接来た人は、入り口へ戻す
def ensure_email_address_pending_authentication
unless email_address_pending_authentication.present?
redirect_to new_session_url, alert: "メールアドレスの入力からやり直してください。"
end
end
def authenticate(magic_link)
if matching_email_address?(magic_link)
clear_pending_authentication_token
start_new_session_for magic_link.user # ← ジェネレータ生成のメソッドをそのまま使う
redirect_to after_authentication_url
else
clear_pending_authentication_token
redirect_to new_session_url, alert: "うまくいきませんでした。もう一度お試しください。"
end
end
# 「コードを要求したブラウザ」と「コードの持ち主」が一致するか
def matching_email_address?(magic_link)
ActiveSupport::SecurityUtils.secure_compare(
email_address_pending_authentication.to_s, magic_link.user.email_address
)
end
end6. ルーティングとビュー
ジェネレータが追加した resource :session の中に、コード入力画面をネストさせます。
# config/routes.rb
resource :session do
scope module: :sessions do
resource :magic_link, only: %i[ show create ]
end
endログイン画面からはパスワード欄を消して、メールアドレスだけにします。
<%# app/views/sessions/new.html.erb %>
<h1>ログイン</h1>
<%= form_with url: session_path do |form| %>
<%= form.email_field :email_address, required: true, autofocus: true,
autocomplete: "username", placeholder: "メールアドレス" %>
<%= form.submit "ログイン用コードを送る" %>
<% end %>コード入力画面では autocomplete: "one-time-code" を付けておきましょう。スマホのOSがワンタイムコードだと認識して、キーボードの上に候補として出してくれるので、入力の手間が体感でぐっと減ります(iOSはメール本文の認証コードも対象になります)。
<%# app/views/sessions/magic_links/show.html.erb %>
<h1>メールを確認してください</h1>
<p><strong><%= email_address_pending_authentication %></strong> 宛にコードを送りました。</p>
<%= form_with url: session_magic_link_path, method: :post do |form| %>
<%= form.text_field :code, required: true, autofocus: true, maxlength: 6,
autocomplete: "one-time-code", autocapitalize: "off", spellcheck: "false",
placeholder: "••••••" %>
<%= form.submit "ログイン" %>
<% end %>
<p>コードは <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %> 有効です。</p>ここまでで、パスワードなしのログインが一通り動きます。実際に動かすと、こんな見た目になります。

開発中は letter_opener を入れておくと、送信されたメールがブラウザで開くので確認がラクです。
7. 最低限これだけは書きたいテスト
認証は「動いた」だけで安心してはいけない領域です。とはいえ全部テストするのも大変なので、壊れたときに一番怖い2つだけ挙げておきます。Fizzy のテストからも、この2ケースはそのまま拝借できます。
# test/controllers/sessions/magic_links_controller_test.rb
class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest
# ① 他人のコードではログインできない(照合が効いているか)
test "他人のコードは弾かれる" do
post session_url, params: { email_address: users(:alice).email_address }
post session_magic_link_url, params: { code: users(:bob).send_magic_link.code }
assert_redirected_to new_session_path
assert_not cookies[:session_id].present?
end
# ② 期限切れのコードは消費されない(使い回されないか)
test "期限切れのコードは使えない" do
magic_link = users(:alice).send_magic_link
magic_link.update_column(:expires_at, 1.hour.ago)
post session_url, params: { email_address: users(:alice).email_address }
post session_magic_link_url, params: { code: magic_link.code }
assert_not cookies[:session_id].present?
assert MagicLink.exists?(magic_link.id), "期限切れのリンクは消費してはいけない"
end
end①が落ちるようになったら、それは誰でも他人のアカウントに入れる状態ということです。実装をリファクタリングしたときの安全網として、必ず置いておきましょう。
ハマりどころ4つ
実装できたあとに効いてくる、見落としやすいポイントをまとめます。
1. SecureRandom.base32 は Rails 8.0 / 8.1 には無い
Fizzy のコードをそのままコピーすると、おそらくここで NoMethodError になります。
SecureRandom.base32 と SecureRandom::BASE32_ALPHABET は ActiveSupport の拡張ですが、Rails 8.0 系・8.1 系のブランチには含まれておらず、main(8.2 系)で追加されたものです。Fizzy 自体が gem "rails", github: "rails/rails", branch: "main" という尖った構成なので、そのまま持ってくると動きません。
先ほど示したように、文字セットを自前で定義してしまえば解決します。「本家のコードを読むときは、そのアプリが乗っている Rails のバージョンも一緒に確認する」——地味ですが、写経で一番ハマるところです。
2. メールアドレスの存在がバレる(ユーザー列挙)
素直に実装すると、こう書きたくなりませんか?
# ❌ 登録の有無が丸わかりになる
def create
if user = User.find_by(email_address: email_address)
redirect_to_session_magic_link user.send_magic_link
else
redirect_to new_session_url, alert: "そのメールアドレスは登録されていません"
end
endこれだと、攻撃者はメールアドレスを順番に入力するだけで「どのアドレスがこのサービスに登録済みか」を判別できてしまいます。ユーザー列挙(user enumeration)と呼ばれる情報漏えいです。
Fizzy はこれを redirect_to_fake_session_magic_link という関数名そのままの方法で潰しています。登録が無いメールアドレスでも、その場でダミーの(保存しない)MagicLink オブジェクトを組み立てて、まったく同じコード入力画面に飛ばすのです。
ダミーはDBに保存されないので、当然どんなコードを入れてもログインはできません。それでも攻撃者から見える画面は登録済みの場合と完全に同一——という仕掛けです。権限まわりの情報漏えいについては「権限昇格」による脆弱性でも触れているので、あわせてどうぞ。
3. 期限切れコードが溜まり続ける
MagicLink は期限が切れても自動では消えません。active スコープでヒットしなくなるだけで、レコードは残り続けます。ログイン頻度が高いサービスほど、じわじわ肥大化していきます。
Solid Queue を使っているなら、config/recurring.yml に1行足すだけで掃除できます(Fizzy は4時間ごとに実行しています)。
# config/recurring.yml
production:
cleanup_magic_links:
command: "MagicLink.cleanup"
schedule: every 4 hoursRails 8 の Solid Queue まわりの設定でつまずいたら、Rails8に入るsolid_queueなどのgemを使った時のdatabase.ymlの書き方も参考にしてください。
4. rate_limit を必ず入れる
6文字のコードは、Crockford Base32(32文字)で 326 = 約10億通りあります。総当たりされる可能性は現実的には低いですが、「コード送信」側の乱用のほうが問題になりやすいです。
他人のメールアドレスを入れて連打されれば、その人の受信箱がコードメールで溢れます。送信元のドメイン評価にも悪影響が出ますよね。Rails 7.2 以降なら rate_limit がコントローラに標準で使えるので、送信側・検証側の両方に必ず設定してください(Fizzy も両方に入れています)。
リンク方式とコード方式、どちらを送るか
前半で保留にしていた「リンクを送るか、コードを送るか」をここで整理します。この記事でコード方式を選んだのには理由があります。
| 観点 | リンク方式 | コード方式 |
|---|---|---|
| ユーザーの手間 | クリック1回で完了 | コードを見て入力する(数秒) |
| 別の端末でメールを開いたとき | 踏んだ端末側でログインしてしまう | メールはスマホ、ログインはPCでもよい |
| メールスキャナ | 自動でURLを開かれ、消費されることがある | 自動では消費されない |
| ブラウザの照合 | URLだけで完結するぶん実装が甘くなりやすい | pending_authentication_token と自然に組み合う |
| 実装コスト | トークン付きURLを発行するだけ | コード生成+入力画面が必要 |
クリック1回で済むリンク方式のほうが一見ラクですが、実務では2つ目と3つ目が地味に効いてきます。
特にメールスキャナに勝手に踏まれる問題は実際に起きます。企業のメールゲートウェイやプレビュー機能は、安全確認のためにURLを自動で開くことがあるからです。使い捨てリンクだとユーザーが開く前にコードが消費されて、ログインできないという状態になります。
法人利用のあるサービスだと、「リンクを押しても無効と言われる」という問い合わせが来てから原因に気づくことになりがちです。
「PCでログインしたいのに、つい手元のスマホでメールを開いてしまった」も日常的に起きます。コード方式なら、どの端末でメールを読んでも、ログインしたいブラウザに打ち込めば済みます。
逆に、コンシューマ向けで入力の手間を1秒でも削りたい場合はリンク方式にも分があります。リンクとコードを両方載せて、ユーザーに選ばせる作りにしているサービスもあるので、迷ったらそこから始めるのも手です。
ログイン状態はどのくらい維持されるのか
前半のデメリットで「メールを開く一手間」を挙げましたが、これはログイン状態を維持すれば、そこまで頻繁には起きません。実際どのくらい維持されるのかを確認しておきましょう。
ここは自分で書いたコードではなく、ジェネレータが生成したコードとブラウザの仕様に委ねている部分なので、意外と見落とされがちです。
Rails側は「実質無期限」
もう一度 start_new_session_for のCookie行を見てみます。
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }permanent は Rails では expires: 20.years.from_now のことです(ActionDispatch::Cookies::PermanentCookieJar)。20年です。
そしてジェネレータが生成する Session モデルは belongs_to :user の1行だけで、サーバー側には有効期限がありません。つまり素の状態では、一度ログインしたらほぼ永続的にログインしたままになります。
ブラウザ側には上限がある
とはいえ20年は通りません。ブラウザ側でCookieの寿命に上限がかかります。
| ブラウザ | 上限 | Railsのセッションクッキーへの影響 |
|---|---|---|
| Chrome / Edge | 400日(Chrome 104・2022年8月〜) | 20年と指定しても400日に切り詰められる。拒否されるわけではない |
| Safari | ITPによる7日上限 | 影響しない。7日上限の対象は document.cookie で作ったCookie。Railsのものは httponly: true のサーバー発行なので対象外 |
| Firefox ほか | 400日(RFC 6265bis で標準化) | Chrome と同じ方向に揃いつつある |
Safariの「7日でログインが切れる」話はよく聞きますが、あれはJavaScriptで作ったCookieの話です。Rails 8のジェネレータは httponly: true でサーバーから発行しているので、ここに引っかかりません。安心してよいポイントです。
結果として、実用上の目安は「最長400日」と考えておけば大きくは外しません。
ただしRailsは有効期限を自動で延長しない
ここが一番の注意点です。この400日は「ログインした日」から数えます。使い続けても伸びません。
理由はシンプルで、Cookieを発行している start_new_session_for がログイン時にしか呼ばれないからです。毎リクエストで走る resume_session は、Cookieを読むだけで書き直していません。
# 生成されたコード:読むだけで、期限は延びない
def resume_session
Current.session ||= find_session_by_cookie
end「最終アクセスから400日」にしたいなら、アクセスのたびにCookieを打ち直します。
# app/controllers/concerns/authentication.rb
def resume_session
Current.session ||= find_session_by_cookie
refresh_session_cookie if Current.session
Current.session
end
private
# アクセスのたびに期限を打ち直して「最終アクセスから」に変える
def refresh_session_cookie
cookies.signed.permanent[:session_id] = {
value: Current.session.id, httponly: true, same_site: :lax
}
end週1回でも使ってもらえるサービスなら、これだけでユーザーはほぼコード入力から解放されます。
結局、コード入力が必要になるのはいつか
ここまでを踏まえると、ユーザーが実際にメールを開く必要があるのは次の場面だけです。
- 初めてログインするとき
- 新しい端末・別のブラウザを使うとき
- プライベートウィンドウを使ったとき、Cookieを消したとき
- 明示的にログアウトしたとき、400日が経過したとき
「ログインのたびに毎回メールを開く」という認識は正確ではありません。体感としては、パスワード認証で「ログイン状態を保持する」にチェックを入れたときと大きく変わらないと考えてよいです。
ただしトレードオフもあります。パスワードが無いぶん、このCookieがログイン状態を保つ唯一の手段です。有効期限を伸ばすほど、端末を他人に触られたときのリスクも一緒に伸びます。
共有PCが想定される業務システムなら、期限を短めにするか、次に説明するパスキーの併用を考えることになります。
パスワードは残すべき?捨てるべき?
最後に判断材料を置いておきます。マジックリンクを入れたからといって、パスワードを消すかどうかは別の話です。
| こんなサービス | おすすめ |
|---|---|
| 個人開発・社内ツール・利用頻度が低いサービス | マジックリンクだけにする。パスワードリセット機能ごと不要になる |
| 共有PC利用やセッションを短く切る業務システム | マジックリンク+パスキーを併用。Cookieを長く保てないぶん、メールを開く頻度が上がってしまう |
| 既にパスワードで運用中のサービス | まずマジックリンクを追加し、利用率を見てからパスワード廃止を判断する |
実際 Fizzy も、マジックリンクを基本にしつつパスキー(WebAuthn)を「もっと速くログインする手段」として案内する作りになっています。コードのメール本文には「パスキーを登録すると、もっと速く安全にログインできます」という追伸まで入っています。
マジックリンクは「全員が確実に使える最低ライン」として置き、頻繁に使う人にはパスキーを勧める。この二段構えは、そのまま真似する価値のある設計だと思います。
迷ったときの一行ルールは、「ログイン状態を長く保てるサービスならマジックリンクだけで十分、セッションを短く切るならパスキーを足す」。まずはマジックリンクを入れて、ユーザーから「メールを開くのが面倒」という声が出てから次を考えれば間に合います。
まとめ
Rails 8 の認証ジェネレータをマジックリンク化する要点を整理します。
- 差し替えるのは本人確認だけ。
Sessionモデルやstart_new_session_forはそのまま流用できる - MagicLink は「6文字・15分・使い捨て」。
consumeでdestroyしてからレコードを返すのが要 - コードは見間違えにくい文字セットで発行し、入力は寛容に受ける。
O→0、I/L→1の読み替えで失敗が激減する - 安全性の肝は署名付きCookie。「コードを要求したブラウザか」を
secure_compareで照合してからログインさせる - ユーザー列挙対策・rate_limit・期限切れの掃除まで入れて、はじめて本番で使える
- ログイン状態はCookie次第。
cookies.signed.permanentは20年でもブラウザ側で最長400日に切り詰められ、しかもRailsは自動で延長しない SecureRandom.base32は Rails 8.2(main)以降。8.0 / 8.1 では自前で文字セットを定義する
認証は「怖いから gem に任せる」と思われがちな領域ですが、Fizzy のコードを読むと、実際に動いているものが驚くほど素朴なことに気づきます。マジックリンク関連はファイル6つ・合わせて300行ほどしかありません。
ジェネレータで生成したコードが自分のアプリの中にある——というのは、こういう改造ができるということでもあります。まずは開発環境で bin/rails generate authentication を叩いて、パスワード欄を消すところから試してみてはいかがでしょうか。
スポンサーリンク
