Warden::Proxy snapshots stale default_scope on first request after boot in Rails 8 dev (LazyRouteSet)
TL;DR
Rails 8's LazyRouteSet defers route loading until the first request. Devise sets warden_config.default_scope = :user from RouteSet#finalize!, but Warden::Proxy snapshots config at Proxy#initialize (@config = manager.config.dup) — so on the first request, the proxy holds a :default snapshot taken before Devise had a chance to set it. Any code that reads warden.session (no scope arg) raises Warden::NotAuthenticated: :default user is not logged in. PR #5728 makes Devise.mappings trigger route loading, but doesn't address the Proxy.dup timing — by the time anything calls Devise.mappings in the request flow, the proxy is already initialized.
Environment
- Devise 5.0.3 (also reproduces on
main, since the relevant code is identical) - Rails 8.0.5
- Warden 1.2.9
- Dev mode:
config.eager_load = false,Rails.env.local? == true→Engine::LazyRouteSetis active
Reproduction
Any Rails 8 + Devise app where some code path reads warden.session without an explicit scope. Steps:
- Stop the dev server.
- Start
bin/rails s. - With a valid Devise session cookie, request any authenticated page.
- →
Warden::NotAuthenticated: :default user is not logged in. - Refresh. → 200 OK.
The error message containing the literal symbol :default (rather than :user) is the giveaway.
Root cause
Boot-order regression introduced by Rails 8 LazyRouteSet
In railties-8.1.3/lib/rails/engine.rb:
initializer :make_routes_lazy, before: :bootstrap_hook do |app|
config.route_set_class = LazyRouteSet if Rails.env.local?
endThen in railties-8.1.3/lib/rails/application/finisher.rb, route eager-loading at boot is gated:
reloader.execute_unless_loaded if !app.routes.is_a?(Engine::LazyRouteSet) || app.config.eager_loadIn dev: LazyRouteSet is in use and eager_load = false → both clauses false → routes are not loaded at boot. Devise's prepended RouteSet#finalize! (devise/lib/devise/rails/routes.rb) and Devise.configure_warden! therefore don't run until the first request hits the router (LazyRouteSet#call calls reload_routes_unless_loaded).
Warden::Proxy snapshots config too early
Warden::Manager#call creates a fresh Proxy per request, and Proxy#initialize snapshots the manager's config (warden/lib/warden/proxy.rb):
def initialize(env, manager)
@manager, @config = manager, manager.config.dup
endmanager.config.dup creates a separate Hash. Subsequent mutations to manager.config don't propagate to the proxy.
The first-request-only asymmetry
| Step | manager.config[:default_scope] |
New Proxy.@config[:default_scope] |
|---|---|---|
| Boot complete | :default |
n/a |
| Request 1 enters Warden middleware | :default |
:default (snapshot) |
Request 1 reaches LazyRouteSet#call → routes load → Devise.configure_warden! |
mutated to :user |
still :default |
Request 1 hits anything that reads warden.session |
:user |
:default → raises NotAuthenticated |
| Request 2 enters Warden middleware | :user |
:user (snapshot) |
| Request 2 hits the same code | :user |
:user → works |
That's why the bug only fires on the first request after boot.
Why PR #5728 doesn't fix it
#5728 made Devise.mappings call Rails.application.try(:reload_routes_unless_loaded). The chain does eventually trickle down to Devise.configure_warden! and mutate Warden::Manager#config[:default_scope]. But by the time anything in the request flow calls Devise.mappings (a controller, helper, route iteration, etc.), the Warden::Proxy has already been created with a snapshot of the stale config.
For #5728 to fix this, route loading would need to be triggered before Warden::Manager#call runs on the first request — or by middleware above Warden in the stack. Neither happens. We verified empirically: #5728 is in 5.0.3, and the bug still reproduces.
Suggested fixes
Three options, in order of preference:
Option A — assign default_scope (and other scope-independent keys) from an after_initialize hook
In Devise::Engine:
config.after_initialize do
if Devise.warden_config
Devise.warden_config.failure_app ||= Devise::Delegator.new
Devise.warden_config.default_scope ||= Devise.default_scope
Devise.warden_config.intercept_401 = false unless Devise.warden_config.key?(:intercept_401)
end
endThis runs after :build_middleware_stack (so Devise.warden_config is set) and before the server accepts requests. The per-mapping setup (serializers, scope_defaults) still happens from RouteSet#finalize! because it depends on Devise.mappings being populated, which only happens during route load.
Option B — split configure_warden! into a boot phase and a routes phase
Move scope-independent config (failure_app, default_scope, intercept_401) into a method invoked from after_initialize. Keep the per-mapping serializer/scope_defaults registration in the routes-finalize phase. Cleaner separation but a bigger refactor.
Option C — hook after_routes_loaded to mirror the existing before_eager_load
Devise already has before_eager_load { app.reload_routes! if Devise.reload_routes } which handles production. The symmetric fix for dev would be to also hook ActiveSupport.on_load(:after_routes_loaded) and invoke Devise.configure_warden! from there — though this still doesn't fix the very-first-request-after-boot case unless that hook fires before Warden middleware, which I don't believe it does.
Option A is the smallest, least invasive change that actually closes the timing window.
Workaround
Apps can mitigate locally by always passing an explicit scope: warden.session(:user) instead of warden.session. That's what we did in our codebase — but it's a sweep across every callsite, and any future bare warden.session reintroduces the bug.
Notes
- This isn't a hypothetical edge case: Devise's README and many Devise tutorials show
warden.session[...](no scope) in custom hook examples. Apps that copied that pattern silently break on Rails 8 dev. - Pre-Rails-8 dev mode loaded routes at boot, so Devise has been silently relying on that timing. Rails 8's lazy routes broke an implicit contract.
- Happy to put up a PR for Option A if it's the direction you'd take.
Source: heartcombo/devise