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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions