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::LazyRouteSet is 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?
end
Then 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_load
In 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
end
manager.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
end
This 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.
TL;DR
Rails 8's
LazyRouteSetdefers route loading until the first request. Devise setswarden_config.default_scope = :userfromRouteSet#finalize!, butWarden::Proxysnapshots config atProxy#initialize(@config = manager.config.dup) — so on the first request, the proxy holds a:defaultsnapshot taken before Devise had a chance to set it. Any code that readswarden.session(no scope arg) raisesWarden::NotAuthenticated: :default user is not logged in. PR #5728 makesDevise.mappingstrigger route loading, but doesn't address theProxy.duptiming — by the time anything callsDevise.mappingsin the request flow, the proxy is already initialized.Environment
main, since the relevant code is identical)config.eager_load = false,Rails.env.local? == true→Engine::LazyRouteSetis activeReproduction
Any Rails 8 + Devise app where some code path reads
warden.sessionwithout an explicit scope. Steps:bin/rails s.Warden::NotAuthenticated: :default user is not logged in.The error message containing the literal symbol
:default(rather than:user) is the giveaway.Root cause
Boot-order regression introduced by Rails 8
LazyRouteSetIn
railties-8.1.3/lib/rails/engine.rb:Then in
railties-8.1.3/lib/rails/application/finisher.rb, route eager-loading at boot is gated:In dev:
LazyRouteSetis in use andeager_load = false→ both clauses false → routes are not loaded at boot. Devise's prependedRouteSet#finalize!(devise/lib/devise/rails/routes.rb) andDevise.configure_warden!therefore don't run until the first request hits the router (LazyRouteSet#callcallsreload_routes_unless_loaded).Warden::Proxysnapshots config too earlyWarden::Manager#callcreates a freshProxyper request, andProxy#initializesnapshots the manager's config (warden/lib/warden/proxy.rb):manager.config.dupcreates a separate Hash. Subsequent mutations tomanager.configdon't propagate to the proxy.The first-request-only asymmetry
manager.config[:default_scope]Proxy.@config[:default_scope]:default:default:default(snapshot)LazyRouteSet#call→ routes load →Devise.configure_warden!:user:defaultwarden.session:user:default→ raises NotAuthenticated:user:user(snapshot):user:user→ worksThat's why the bug only fires on the first request after boot.
Why PR #5728 doesn't fix it
#5728 made
Devise.mappingscallRails.application.try(:reload_routes_unless_loaded). The chain does eventually trickle down toDevise.configure_warden!and mutateWarden::Manager#config[:default_scope]. But by the time anything in the request flow callsDevise.mappings(a controller, helper, route iteration, etc.), theWarden::Proxyhas 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#callruns 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 anafter_initializehookIn
Devise::Engine:This runs after
:build_middleware_stack(soDevise.warden_configis set) and before the server accepts requests. The per-mapping setup (serializers,scope_defaults) still happens fromRouteSet#finalize!because it depends onDevise.mappingsbeing populated, which only happens during route load.Option B — split
configure_warden!into a boot phase and a routes phaseMove scope-independent config (
failure_app,default_scope,intercept_401) into a method invoked fromafter_initialize. Keep the per-mapping serializer/scope_defaultsregistration in the routes-finalize phase. Cleaner separation but a bigger refactor.Option C — hook
after_routes_loadedto mirror the existingbefore_eager_loadDevise already has
before_eager_load { app.reload_routes! if Devise.reload_routes }which handles production. The symmetric fix for dev would be to also hookActiveSupport.on_load(:after_routes_loaded)and invokeDevise.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 ofwarden.session. That's what we did in our codebase — but it's a sweep across every callsite, and any future barewarden.sessionreintroduces the bug.Notes
warden.session[...](no scope) in custom hook examples. Apps that copied that pattern silently break on Rails 8 dev.