Wardenは、Rackミドルウェアとして動くRubyの認証フレームワークです。認証の方法そのものは「戦略(strategy)」というクラスに書き、Wardenはどの戦略をどの順で試すか、成功したユーザーをセッションに残すか、失敗時に何を返すかを受け持ちます。Deviseもこの上に載っています。この記事では、Warden 1.2.9をRack 3.2.7とRuby 4.0.6で動かした結果をもとに、カスタム戦略の書き方とDeviseへの組み込み、テストまでを順に説明します。
まとめ:カスタムWarden戦略を書くときの要点
- 戦略は
Warden::Strategies::Baseを継承し、authenticate!を定義してWarden::Strategies.addで登録します。authenticate!が無いと登録時にNoMethodErrorになります。 valid?は「この戦略を試すかどうか」の判定です。認証情報の正否ではなく、ヘッダやパラメータが揃っているかだけを見ます。fail!は後続の戦略を止め、failは次の戦略へ進めます。実測ではfailにすると最後に失敗した戦略のメッセージが返りました。- APIトークンやJWTの戦略は
store?をfalseにします。実測ではSet-Cookieが付かず、次のリクエストはトークン無しで401になりました。ただし同じスコープに既存のログインセッションがあると、戦略そのものが実行されません。 - Deviseでは
config.wardenの中でdefault_strategies(scope: :user).unshiftすると、Devise標準の戦略より先に自作の戦略が試されます。 - Warden 1.2.9の
redirect!はLocationを大文字で返すため、Rack 3のRack::Lintで例外になります。Lintを挟む環境ではcustom!で小文字のヘッダを返します。
Wardenの構成要素と2026年時点の版
Wardenをuseすると、リクエストごとにenv["warden"]へWarden::Proxyが入ります。アプリ側はこのProxyのauthenticate!やuserを呼ぶだけで、どの戦略を使うかは設定と戦略側のvalid?で決まります。
| 要素 | 役割 |
|---|---|
Warden::Manager |
Rackミドルウェア本体・設定 |
Warden::Proxy |
env["warden"]の実体 |
| 戦略(Strategy) | 1つの認証方式の実装 |
failure_app |
認証失敗時のレスポンスを返すRackアプリ |
| シリアライザ | ユーザーとセッション値の相互変換 |
RubyGems上の最新版は1.2.9(2020年8月31日公開)で、それ以降のリリースはありません。ただしリポジトリ(wardencommunity/warden)は動いており、Ruby 4.0をCIの対象に加える変更(PR #222)が2026年9月4日にmasterへマージされています。依存はgemspecのrack >= 2.0.9だけで、Rack 3系でもそのまま入ります。Devise 5.0.4(2026年5月8日公開)はwarden ~> 1.2.3に依存しているので、Deviseを入れたRailsアプリには必ずWardenも入っています。Ruby本体の版はRubyの最新バージョンとサポート期限で確認してください。
カスタム戦略の最小構成:valid?とauthenticate!
次のconfig.ruは、APIトークンとパスワードの2つの戦略を登録し、どちらかで認証できたら200、できなければfailure_appが401を返す最小構成です。Userはトークン・ユーザー名・IDで検索できるモデルを想定しています。パスワード検証にはpassword_digestとhas_secure_passwordを用意します。Rack 3ではセッション用のrack-sessionも別途導入し、SESSION_SECRETには暗号学的に安全な乱数から生成した64バイト以上の秘密値を設定します。
# config.ru(Warden 1.2.9 / Rack 3.2.7 / Ruby 4.0.6 で実行)
require "warden"
require "rack/session"
require "json"
class ApiTokenStrategy < Warden::Strategies::Base
# Authorizationヘッダが無いリクエストではこの戦略を飛ばす
def valid?
env["HTTP_AUTHORIZATION"].to_s.start_with?("Bearer ")
end
# APIトークンはリクエストごとに送られるのでセッションへ保存しない
def store?
false
end
def authenticate!
token = env["HTTP_AUTHORIZATION"].delete_prefix("Bearer ")
user = User.find_by_token(token)
user ? success!(user) : fail!("invalid token")
end
end
class PasswordStrategy < Warden::Strategies::Base
def valid?
params["username"] && params["password"]
end
def authenticate!
user = User.find_by_name(params["username"])
if user && user.authenticate(params["password"])
success!(user)
else
fail!("invalid password")
end
end
end
Warden::Strategies.add(:api_token, ApiTokenStrategy)
Warden::Strategies.add(:password, PasswordStrategy)
failure_app = lambda do |env|
opts = env["warden.options"]
[401, { "content-type" => "application/json" },
[JSON.generate(error: opts[:message], attempted: opts[:attempted_path])]]
end
use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), key: "_s"
use Warden::Manager do |manager|
manager.default_strategies :api_token, :password
manager.failure_app = failure_app
manager.serialize_into_session { |user| user.id }
manager.serialize_from_session { |id| User.find(id) }
end
run lambda { |env|
user = env["warden"].authenticate!
[200, { "content-type" => "application/json" }, [JSON.generate(user: user.name)]]
}
Rack::Testで送った結果は次のとおりです。
| リクエスト | 応答 | Set-Cookie |
|---|---|---|
| 正しいBearerトークン | 200 | なし |
| 認証情報なし | 401(error: null) | なし |
| パスワード誤り | 401(invalid password) | なし |
| パスワード正解 | 200 | あり |
| 直後に認証情報なし | 200(セッションから復元) | あり |
認証情報が無いときにerrorがnullになるのは、どの戦略のvalid?もfalseを返し、メッセージを設定した戦略が1つも無いためです。failure_appで「認証情報がありません」を出したいなら、メッセージが空のケースを自分で分岐させます。
登録時のチェックはWarden::Strategies.addのソースにあり、authenticate!が未定義ならNoMethodError、Warden::Strategies::Baseの子孫でなければRuntimeErrorになります。登録していない名前をauthenticate!(:nope)のように渡すと、リクエスト時にRuntimeError: Invalid strategy nopeで落ちます。
判定メソッドの使い分け:success!・fail!・fail・redirect!・custom!
authenticate!の中では、次のどれかを呼んで結果を決めます。「停止」は後続の戦略を試さないことを指します。
| メソッド | 結果 | 停止 | 主な用途 |
|---|---|---|---|
success!(user) |
:success | する | 認証成功 |
fail!(msg) |
:failure | する | 確定的な失敗 |
fail(msg) |
:failure | しない | 次の戦略に任せる |
pass |
変更なし | しない | 何もしない |
redirect!(url) |
:redirect | する | 外部IdPへ送る |
custom!(rack_response) |
:custom | する | 応答を丸ごと指定 |
fail!やredirect!を呼んでもその場でレスポンスは返りません。戦略は結果を記録するだけで、env["warden"].authenticate!がユーザー不在を見てthrow(:warden)し、Managerがfailure_appやredirect!の内容を返します。authenticate(感嘆符なし)を使うとthrowせずnilが返ります。
fail!とfailの違い:後続戦略の停止と継続
パスワード戦略の後ろに、ユーザー名があれば必ずfail!("fallback also failed")する:fallback戦略を置き、パスワード誤りのリクエストを送りました。
# PasswordStrategy が fail! で失敗(次の :fallback は実行されない)
fail! calls=[:password]
401 {"error":"invalid password"}
# PasswordStrategy が fail で失敗(:fallback まで進み、最後の失敗メッセージが残る)
fail calls=[:password, :fallback]
401 {"error":"fallback also failed"}
LDAPで失敗したらDBのパスワードを試す、という段階的な認証ではfailを使います。逆に、トークンの署名が不正だった場合に別の戦略へ進ませると、意図しない方式で認証が通る余地が生まれます。トークン系の戦略はfail!で止めるのが安全です。
同一リクエスト・同一スコープでの戦略実行キャッシュ
同じリクエストの中でauthenticate!を2回呼んでも、戦略は2回目には実行されません。Proxyは戦略のインスタンスをスコープごとにキャッシュし、performed?が真の戦略を飛ばします。実測でも呼び出し記録は[:api_token]の1件でした。コントローラのbefore_actionとビューの両方でauthenticate!を呼んでも、同じスコープで戦略キャッシュを保持している限り、戦略内のDB検索やJWT検証は再実行されません。別スコープでの認証やclear_strategies_cache!によるキャッシュ解除では、同じリクエストでも再実行されます。
redirect!とRack 3:大文字ヘッダによるRack::Lintエラー
Warden 1.2.9のredirect!は、レスポンスヘッダをheaders["Location"]とheaders["Content-Type"]の大文字で組み立てます(2026年10月時点のmasterも同じです)。Rack 3はレスポンスヘッダ名を小文字に統一しており、Rack::Lintを挟むと次の例外になります。
Rack::Lint::LintError: uppercase character in header name: Location
rackup 2.3.1はdevelopment環境の既定ミドルウェアにRack::Lintを含むため、素のRackアプリをrackupで開発していると当たります。Lintを外した状態では302とlocationが返り、Railsのrails serverは既定ミドルウェアを空にしているので影響を受けません。Lintを残したい場合は、custom!で小文字のヘッダを持つレスポンスを返します。次のコードはRack::Lintを挟んだ状態で302を返しました。
class SsoStrategy < Warden::Strategies::Base
def valid? = params["sso"] == "1"
def authenticate!
url = "https://idp.example.com/authorize?" + Rack::Utils.build_query(client_id: "abc")
custom!([302, { "location" => url, "content-type" => "text/plain" }, ["redirecting"]])
end
end
APIトークンとJWTの戦略:store?をfalseにする理由
store?の既定値はtrueで、成功したユーザーはセッションに保存されます。APIクライアントに毎回トークンを要求する場合、認証結果をセッションへ保存しない設定が必要です。前の章の実測でも、store?をfalseにしたトークン認証ではSet-Cookieが付かず、トークンを外した次のリクエストは401でした。ただし、store?をfalseにしても既存セッションの読み出しは止まりません。Wardenは同じスコープのユーザーをセッションから復元できると戦略を実行しないため、パスワード認証との共用スコープではトークンなしでも通ります。実測でも、パスワードでログインした同じセッションに不正なトークンを付けて送ると200が返りました。トークン必須のAPIは、セッション認証と分離したスコープ・構成で扱います。
JWTの戦略は次のように書けます。jwt gemの最新版は3.3.0(2026年9月11日公開)です。この例は前掲の固定APIトークン戦略とは別の認証方式です。登録後はJWT用の構成でauthenticate!(:jwt)を呼ぶか、既定戦略を:jwtに設定します。前掲の:api_tokenを先頭に残すと、同じBearerヘッダを先に処理してfail!するため、JWT戦略まで進みません。
require "jwt" # jwt 3.3.0
class JwtStrategy < Warden::Strategies::Base
def valid?
env["HTTP_AUTHORIZATION"].to_s.start_with?("Bearer ey")
end
def store?
false
end
def authenticate!
token = env["HTTP_AUTHORIZATION"].delete_prefix("Bearer ")
payload, = JWT.decode(token, ENV.fetch("JWT_SECRET"), true,
algorithm: "HS256", required_claims: ["exp", "sub"])
user = User.find_by(id: payload["sub"])
user ? success!(user) : fail!("unknown subject")
rescue JWT::ExpiredSignature
fail!("token expired")
rescue JWT::DecodeError => e
fail!("invalid token: #{e.class.name.split('::').last}")
end
end
Warden::Strategies.add(:jwt, JwtStrategy)
同じ秘密鍵で5種類のトークンを作り、検証結果を確かめました。
| 送ったトークン | 応答 | 捕捉した例外 |
|---|---|---|
| 有効期限内 | 200 | なし |
| exp切れ | 401 | ExpiredSignature |
| expなし | 401 | MissingRequiredClaim |
| alg=none | 401 | IncorrectAlgorithm |
| 別の鍵で署名 | 401 | VerificationError |
algorithm: "HS256"を明示しているので、署名なしのalg=noneは受け付けません。required_claimsを省くとexpの無いトークンが通り、有効期限による拒否が行われなくなります。JWTの構造や署名方式の選び方はJWTとは?構造・署名検証の仕組みとセッション・OAuth/OIDCとの違い、トークン寿命の決め方はAPI認証の設計|方式選定マトリクスとトークン寿命・監査ログの決め方で扱っています。
固定のAPIトークンを照合する場合は、DBには平文でなくSHA-256などのハッシュを保存し、受け取ったトークンをハッシュしてから検索します。通常の文字列比較である==には、秘密値の比較に必要な定時間実行の保証がありません。パスワードは平文で保存・比較せず、専用のパスワードハッシュを保存します。Railsのhas_secure_passwordを使う場合は、user.authenticateで入力値を検証します。
Deviseにカスタム戦略を組み込む手順
Devise 5.0.4は、ルーティング読み込み後のDevise.configure_warden!で、モデルごとのスコープ(:userなど)に標準の戦略を設定します。戦略の順序は、選択したモジュールをDevise::ALL順に整列し、対応する戦略を重複排除したうえで逆順にしたもので、:database_authenticatableと:rememberableを持つUserなら[:rememberable, :database_authenticatable]です。config.wardenのブロックはこの設定の後に実行されるので、そこで先頭に自作の戦略を差し込めます。
# config/initializers/warden_api_token.rb
class ApiTokenAuthenticatable < Devise::Strategies::Base
def valid?
env["HTTP_AUTHORIZATION"].to_s.start_with?("Bearer ")
end
def store?
false
end
def authenticate!
token = env["HTTP_AUTHORIZATION"].delete_prefix("Bearer ")
user = mapping.to.find_by(api_token_digest: Digest::SHA256.hexdigest(token))
user ? success!(user) : fail!(:invalid_token)
end
end
# config/initializers/devise.rb
Devise.setup do |config|
config.warden do |manager|
manager.strategies.add(:api_token, ApiTokenAuthenticatable)
manager.default_strategies(scope: :user).unshift(:api_token)
end
end
default_strategies(scope: :user)は設定済みの配列そのものを返すため、unshiftで破壊的に先頭へ追加できます。Warden::Configで同じ操作をすると、結果は[:api_token, :rememberable, :database_authenticatable]になりました。戦略クラスをconfig/initializersに置いているのは、初期化の途中で読まれる設定からapp配下の再読み込み対象クラスを参照しないためです。
Devise::Strategies::Baseを継承すると、スコープに対応するモデルをmapping.toで取れます。注意点が2つあります。
- Devise連携時は
ActionDispatch::Requestとなり、request.authorizationも使えます。素のWardenではrequestがRack::Requestなので、Railsのrequest.authorizationはありません(Rack 3.2.7にauthorizationメソッドは無い)。ヘッダはenv["HTTP_AUTHORIZATION"]から読みます。 fail!にシンボルを渡すと、DeviseのFailureAppがdevise.failure.invalid_tokenのキーで翻訳します。キーが無ければ翻訳欠落の文言が出るので、ロケールに足しておきます。
# config/locales/devise.ja.yml(fail!(:invalid_token) の表示文言)
ja:
devise:
failure:
invalid_token: "APIトークンが無効です。"
失敗時のレスポンスはDevise::FailureAppが決めます。http_auth?はXHRならDevise.http_authenticatable_on_xhr、それ以外はリクエスト形式がナビゲーション形式(既定は*/*・:html・:turbo_stream)でなければ真になり、JSONのAPIには401、HTMLにはログイン画面へのリダイレクトが返ります。なおDevise::Strategies::Base#store?はCSRF検証に失敗したリクエスト(env["devise.skip_storage"])でセッション保存を止めるための実装です。上の例のようにfalseで上書きすると、その判定ごと不要になります。DeviseとSorceryの違いはSorcery gemでRailsに認証機能を実装する手順|Deviseとの違い・最新0.18.0対応、Rails APIでJWTを扱う全体像はRails APIとは?作成手順・JWT認証・React連携・Docker環境を実装解説【Rails 8.1】を参照してください。
スコープ・セッション・フックの扱い
スコープは「誰としてログインしているか」の区画で、authenticate!(scope: :admin)のように指定します。ユーザーはセッションのwarden.user.<スコープ>.keyに保存されるため、一般ユーザーと管理者を同じブラウザで同時にログインさせられます。default_strategiesもスコープごとに分けられ、管理者だけパスワード+ワンタイムコードの戦略にする、といった構成が取れます。
セッションまわりで押さえておく挙動は2つです。
set_userはセッションに保存するとき、rack.session.optionsにrenew: trueを立てます。ログインのたびにセッションIDが振り直されるので、セッション固定化攻撃への対策は戦略側で書かなくて済みます。logoutを引数なしで呼ぶとセッション全体をリセットし、logout(:admin)のようにスコープを渡すとそのスコープだけを消します。管理者画面からのログアウトで一般ユーザーまで落としたくない場合はスコープを渡します。
セッションIDの発行から失効までの一般的な設計はセッション管理とは?セッションIDの発行から失効までの実装と保存先選定を解説にまとめています。
after_set_userによる停止済みアカウントのアクセス拒否
Wardenのフックはafter_set_user・after_authentication・after_fetch・before_failure・after_failed_fetch・before_logout・on_requestの7種類です。ログイン後にアカウントを停止されたユーザーを次のリクエストで締め出すには、セッションから復元したとき(event: :fetch)にも走るafter_set_userを使います。
Warden::Manager.after_set_user except: :authentication do |user, auth, opts|
if user.disabled?
scope = opts[:scope]
auth.logout(scope)
throw(:warden, scope: scope, message: "account disabled")
end
end
ログイン後にユーザーを停止状態へ変え、同じセッションで次のリクエストを送ると、401とaccount disabledが返りました。except: :authenticationは、認証成功直後のイベントを検査対象から外す指定です。前掲の戦略には停止状態の判定がないため、このまま併用すると停止済みユーザーの初回認証は通ります。初回認証も拒否する構成では、このオプションを付けずにフックを登録します。
カスタム戦略のテスト:単体テストとWarden::Test::Helpers
戦略はRackのenvを渡せば単体で生成できます。Rack::MockRequest.env_forでヘッダを組み立て、authenticate!を呼んでresult・user・message・halted?を確かめます。次のMinitestは、前掲のApiTokenStrategyを読み込み、tok-aliceに対して名前がaliceのユーザーを返す検証用モデルを用意した状態で、3件とも通りました。
require "minitest/autorun"
class ApiTokenStrategyTest < Minitest::Test
def strategy_for(authorization)
env = Rack::MockRequest.env_for("/me", "HTTP_AUTHORIZATION" => authorization)
ApiTokenStrategy.new(env, :default)
end
def test_valid_token
s = strategy_for("Bearer tok-alice")
assert s.valid?
s.authenticate!
assert_equal :success, s.result
assert_equal "alice", s.user.name
end
def test_invalid_token
s = strategy_for("Bearer nope")
s.authenticate!
assert_equal :failure, s.result
assert_equal "invalid token", s.message
assert s.halted?
end
def test_skips_without_header
refute strategy_for(nil).valid?
end
end
# => 3 runs, 7 assertions, 0 failures, 0 errors, 0 skips
リクエストを通したテストでは、Warden::Test::Helpersのlogin_asで戦略を経由せずにログイン状態を作れます。
require "warden/test/helpers"
include Warden::Test::Helpers
login_as(user) # 次のリクエストで :default スコープにログイン済みにする
login_as(admin, scope: :admin)
get "/me" # Rack::Test などでリクエストを送る
Warden.test_reset! # 未実行の login_as を破棄する(teardown で呼ぶ)
login_asは「次のリクエストでset_userする」予約で、ユーザーはセッションに保存されます。Warden.test_reset!が消すのは未実行の予約だけで、Rack::TestのCookieに残ったセッションは消えません。実測でもtest_reset!の後のリクエストは200のままでした。テスト間でログイン状態を切るには、Cookieを持つセッションごと作り直します。DeviseのIntegrationHelpersのsign_inは内部でlogin_asを呼んでいるので、同じ注意が当てはまります。RSpecでの書き方はRSpecとは?Rubyのテスト自動化の書き方とrspec-rails導入・RSpec 4移行を解説を参照してください。
よくあるエラーと原因
| 症状 | 主な原因 |
|---|---|
| 常に401でメッセージがnull | 全戦略のvalid?がfalse |
| Invalid strategy xxx | Strategies.addの登録漏れ |
| No Failure App provided | failure_app未設定 |
| ログインが次のリクエストで消える | store?がfalse/セッション層がWardenより内側 |
| LintError: uppercase character | redirect!とRack::Lintの組み合わせ |
| Deviseで自作戦略が呼ばれない | unshiftしたスコープ名の違い |
セッションが保持されない問題は、ミドルウェアの順序が原因のことが多くあります。Wardenはセッションを読み書きするので、Rack::Session::Cookieなどのセッション層をWarden::Managerより先にuseします。順序が逆だとenv["rack.session"]が無い状態でWardenが動きます。
Wardenで戦略を書くべきでない場面
カスタム戦略を書くのは、既存のgemが用意していない認証方式を、既存のセッション管理やDeviseの仕組みに相乗りさせたい場合です。次の場面では自作しない方が保守の負担が軽くなります。
- メールとパスワードだけの新規Railsアプリ:Rails 8.0で追加された
bin/rails generate authenticationは、DBに保存するセッションとパスワードリセットのコードを生成します。生成コードはWardenを使っておらず(Rails 8.1.4のテンプレートにwardenの語は0件)、全体を自分のコードとして読めます。 - Google・GitHubなどのOAuth/OIDCログイン:
redirect!で自作するより、OmniAuthとDeviseのomniauthableを使う方が、stateパラメータやコールバックの検証を自分で書かずに済みます。 - Deviseの標準機能で足りる要件:ロック・確認メール・remember meはDeviseのモジュールにあります。自作の戦略で同じことをすると、Devise側の更新に追随する責任を自分で持つことになります。
逆に、社内の独自トークン、APIキー、リバースプロキシが付けるヘッダでの認証、LDAPとDBを順に試す構成は、戦略1つ分のコードで済むのでWardenの出番です。
よくある質問
Wardenとは何ですか?
Rackミドルウェアとして動くRubyの認証フレームワークです。認証方式は戦略クラスに書き、Wardenは戦略の実行順・セッションへの保存・失敗時の応答を受け持ちます。RailsでもSinatraでも素のRackアプリでも使えます。
WardenとDeviseの違いは何ですか?
Wardenは認証の枠組みだけを持ち、ユーザーモデル・画面・ルーティングを持ちません。DeviseはWardenの上に、データベース認証・パスワードリセット・確認メールなどのモジュールとRails用のコントローラを載せたgemです。Devise 5.0.4はwarden ~> 1.2.3に依存しています。
Wardenは今もメンテナンスされていますか?
gemの最新版は2020年8月31日公開の1.2.9で、それ以降リリースはありません。リポジトリではRuby 4.0をCIに加える変更が2026年9月にマージされています。Rack 3系でも動きますが、redirect!のヘッダ名がRack::Lintに引っかかる点は残っています。
fail!で渡したメッセージはどこで受け取れますか?
failure_appに渡るenvのenv["warden.options"][:message]に入ります。試行したパスはenv["warden.options"][:attempted_path]です。DeviseのFailureAppは、メッセージがシンボルの場合にi18nのキーとして翻訳し、文字列の場合はその文字列を使います。
Rails 8の認証ジェネレーターはWardenを使っていますか?
使っていません。bin/rails generate authenticationが生成するコードは、署名付きCookieに入れたセッションIDでDBのSessionを引く実装で、Wardenには依存しませんが、パスワードのハッシュ化には外部gemのbcryptを使います。