Skip to content

Warden::Proxy snapshots stale default_scope on first request after boot in Rails 8 dev (LazyRouteSet) #5844

Description

@ngan

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? == trueEngine::LazyRouteSet is active

Reproduction

Any Rails 8 + Devise app where some code path reads warden.session without an explicit scope. Steps:

  1. Stop the dev server.
  2. Start bin/rails s.
  3. With a valid Devise session cookie, request any authenticated page.
  4. Warden::NotAuthenticated: :default user is not logged in.
  5. 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 :defaultraises 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions