Where Does current_user Actually Live?
The request-local life of identity across Warden, Devise, CurrentAttributes, and the Rails Executor.
Most Rails applications eventually place a surprising amount of trust in a single method call.
current_userControllers branch on it, views render navigation from it, authorization policies depend on it, audit trails often assume it, and service objects sometimes reach for Current.user as if authenticated identity were ambient process state.
The method reads like a global lookup, but a threaded Rails server cannot afford global identity. Puma may run many requests in the same process at the same time. A thread that handled one user’s request a moment ago may handle someone else’s request next. If current_user were merely “the user stored somewhere nearby”, Rails applications would leak identity across requests under ordinary production traffic.
The useful question is not only where the helper is defined, but also which object owns the authenticated identity at each layer of execution, what survives between requests, what is reconstructed for each request, and what gets cleared before the execution context is reused.
In a Devise-backed application, the chain looks like this:
between requests
the session carries a serialized authentication key
inside Rack
env["warden"] points to a request-local Warden::Proxy
inside Warden
the proxy deserializes and memoizes users by scope
inside Action Controller
Devise exposes current_user and memoizes it on the controller
inside application code
Current may mirror selected request-wide attributes
after execution
executor completion callbacks clear CurrentAttributesThe Common Misread
The comfortable mental model is:
current_user
# => the logged-in userThat is useful shorthand for day-to-day controller code, but it hides the parts of the system that matter for concurrency. A more precise model should be:
current_user
# => the authenticated user for this request and scope,
# reconstructed from request/session state,
# cached along the request path,
# and unavailable once the request boundary is goneThe word “current” does not mean “current” in the Ruby process. It means current to an execution context, usually the HTTP request currently being handled by one controller instance, and the boundary becomes visible as soon as code leaves the request path:
class ExportsController < ApplicationController
def create
ExportReportJob.perform_later(params[:report_id])
redirect_to exports_path, notice: "Export started"
end
endIf the job reaches back into request-local identity, the code has smuggled a controller assumption into a process that has no browser request:
class ExportReportJob < ApplicationJob
def perform(report_id)
report = Current.user.reports.find(report_id)
report.generate!
end
endInline job execution in tests can mask this bug because the job may run before the request context has been torn down. A real queue worker has no controller instance, no request env, no browser cookie, and no authentication callback that just executed. The durable value crossing that boundary should be a small identifier, and the worker should resolve authorization again where the work runs.
class ExportsController < ApplicationController
def create
ExportReportJob.perform_later(params[:report_id], current_user.id)
redirect_to exports_path, notice: "Export started"
end
end
class ExportReportJob < ApplicationJob
def perform(report_id, user_id)
user = User.find(user_id)
report = user.reports.find(report_id)
ExportReport.call(report:, actor: user)
end
endThe job starts a new execution with explicit inputs rather than continuing the request, using the same boundary pattern Rails uses internally: carry a compact identity pointer across the boundary, then resolve the live record in the context where the work happens.
The Ownership Chain
Let’s start with a request path before looking at any helper method:
Browser
|
| Cookie: _app_session=...
v
Rack env
|
| HTTP headers, path, method, body, cookies
v
ActionDispatch session middleware
|
| loads session data into env["rack.session"]
v
Warden::Manager middleware
|
| env["warden"] = Warden::Proxy.new(env, manager)
| no user object is loaded just because the proxy exists
v
Rails router and controller dispatch
|
| before_action :authenticate_user!
| current_user -> warden.authenticate(scope: :user)
v
Warden::Proxy
|
| checks its per-request @users cache
| fetches "warden.user.user.key" from the Rack session when needed
| deserializes the key into a User record
v
ActionController instance
|
| Devise helper memoizes @current_user for this controller instance
| helper_method exposes the same controller method to views
v
Current, if the app uses it
|
| Current.user = current_user
| Rails isolated execution state stores the request context
v
Rails Executor completion
|
| executor completion callbacks clear CurrentAttributes
| Rails execution context is unwound
v
The thread returns to the pool without carrying this request's Current state.Some requests will not traverse this entire path. In many production deployments, a CDN, reverse proxy, web server, or Rack server can serve static files before Rails is involved. In development, test, or application-served file paths, more requests may pass through the Rails stack. The architecture to keep in mind is not “every asset request loads a user”; it is “Warden installs a request-local proxy lazily, and user materialization only happens when application code asks for identity”, which is why the env[“warden”] proxy can exist on a request without making a database query.
The Session Carries a Pointer
A basic custom authentication flow shows the same lifecycle without involving Devise:
class SessionsController < ApplicationController
def create
if user = User.authenticate_by(params.permit(:email_address, :password))
reset_session
session[:user_id] = user.id
redirect_to dashboard_path
else
redirect_to new_session_path, alert: "Try another email or password."
end
end
endThe session stores this:
session[:user_id] = user.idIt does not store this:
session[:user] = userWe traced the default CookieStore pipeline in detail earlier; here, the important part is the object boundary: the browser does not bring a Ruby object back to Rails. It brings headers and cookies. Rails loads session data from those cookies or from whatever session store the application uses. The application then decides whether the identity pointer still resolves to a valid server-side user.
A small hand-rolled helper makes that lifecycle visible:
class ApplicationController < ActionController::Base
helper_method :current_user
private
def current_user
return @current_user if defined?(@current_user)
@current_user = User.active.find_by(id: session[:user_id])
end
endThis method performs three separate jobs:
read the identity pointer from the session
query the current server-side record
memoize the result on this controller instance
The defined?(@current_user) guard is deliberate. It caches nil as well as a user record, so an authenticated request does not repeat the same lookup every time a layout, partial, or policy asks for the user.
Every request repeats the reconstruction, and that repetition is not a wasteful ceremony. It is where the browser’s claim meets the database’s current truth: a user may have been deleted, locked, disabled, removed from an account, or forced through a password reset since the cookie was issued. The request boundary is where stale identity is either accepted, rejected, or downgraded.
The durable value is session[:user_id], while the request-local value is @current_user, and the authentication code becomes hard to reason about when those two lifetimes are treated as the same kind of state.
What Devise Adds
Devise keeps the same lifecycle, but delegates most of the Rack-level work to Warden.
Warden is Rack middleware whose manager receives the Rack env, creates a proxy for the request, and stores it where downstream code can find it.1
# Conceptual shape of Warden::Manager
def call(env)
env["warden"] = Warden::Proxy.new(env, self)
@app.call(env)
endThat proxy is stateful, but only for this request:
# Conceptual shape of Warden::Proxy initialization
def initialize(env, manager)
@env = env
@manager = manager
@users = {}
endThe proxy has access to the Rack session through env[“rack.session”]. Warden’s session serializer stores authentication data under a scope-specific key.2
def key_for(scope)
"warden.user.#{scope}.key"
endFor the default Devise user scope, the session key is:
warden.user.user.keyThe value stored there is a compact serialized authentication key, not the Active Record object. Devise’s serializer can later turn that key back into a model record, and the exact serialized shape depends on its mapping and serializer configuration.
When the application code asks Warden for a user, Warden first checks the proxy’s per-request cache. If nothing has been loaded for that scope, it fetches from the session serializer and memoizes the deserialized user on the proxy.3
# Simplified from Warden::Proxy#user
def user(scope)
return @users[scope] if @users.key?(scope)
if user = session_serializer.fetch(scope)
@users[scope] = set_user(user, scope: scope, event: :fetch)
end
endAuthentication methods such as authenticate and authenticate! can go further: if a session-backed user is not already available, they may run configured strategies and trigger failure behavior. In middleware and telemetry code, the difference between user as an identity fetch and authenticate! as an authentication operation is not cosmetic.
Devise exposes this through controller helpers. The generated helper roughly looks like this. 4
def warden
request.env["warden"] || raise Devise::MissingWarden
end
def current_user
@current_user ||= warden.authenticate(scope: :user)
endThere are two caches in play after the first successful lookup:
Warden::Proxy @users[:user]
per-request Rack authentication cache
ApplicationController @current_user
per-controller-instance helper cacheNeither cache is the login or survives the request; both are short-lived Ruby objects built from request-backed state.
The View Gets a Bridge
current_user feels more ambient than it is because views can call it.
In a custom implementation, the bridge is usually explicit:
helper_method :current_userRails’ helper_method exposes selected controller methods to the view context. Devise registers its mapping helpers in the same spirit, so this layout code:
<% if current_user %>
<%= link_to current_user.email_address, account_path %>
<% end %>is still calling a controller helper for the current request. The view has gained a method bridge back into the controller/request context, not access to global authentication state.
That is why the same method is not available in jobs, mailers invoked outside a request, raw model code, concise sessions, and arbitrary threads. Those execution contexts do not have the controller helper bridge unless the application explicitly builds one.
Current Is a Mirror, Not the Source
At some point, a mature Rails codebase usually wants identity deeper than controllers: audit logs need an actor, multi-tenant models need an account, service objects need request metadata, and passing five arguments through every call can become noisy. Rails provides ActiveSupport::CurrentAttributes for that narrow category of request-wide state.
A robust Current class should be small and boring:
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :user, :account
attribute :request_id, :ip_address, :user_agent
resets { Time.zone = nil }
def user=(user)
super
Time.zone = user&.time_zone
end
endThen a controller explicitly bridges authenticated identity into request-wide application context:
class ApplicationController < ActionController::Base
before_action :authenticate_user!
before_action :set_current_context
private
def set_current_context
Current.user = current_user
Current.request_id = request.uuid
Current.ip_address = request.ip
Current.user_agent = request.user_agent
end
endCurrent.user = current_user doesn’t explain where current_user came from. It copies the already-authenticated request user into Rails’ current execution context. If the authentication layer does not run, or the controller does not perform the bridge, Current.user is not populated by virtue of the class existing.
The Rails API documentation for CurrentAttributes is intentionally cautious: Current should hold only a few top-level globals, such as account, user, and request details, that are used across most actions. Controller-specific state does not belong there because it turns ordinary method calls into hidden dependencies on request context.
In practice, Current is good for values like:
Current.user
Current.account
Current.request_id
Current.ip_address
Current.user_agentIt is a poor home for values like:
Current.invoice
Current.search_query
Current.checkout_step
Current.feature_flag_override_for_this_one_actionThose may be legitimate values, but they are not part of the application-wide request context. A useful test: if the value only makes sense for one controller action, it probably doesn’t belong in Current. It belongs in arguments, query objects, form objects, policy context, or explicit service input.
Isolation and Teardown
The old version of this pattern was usually something like Thread.current[:user] = current_user. It works until it doesn’t, and the failure mode is ugly because web server threads are pooled. If a thread finishes one request and the application forgets to clear the thread local, the next request handled by that thread can observe stale identity.
CurrentAttributes gives a safer primitive, but the important property is lifecycle ownership, not the class name.
Rails describes CurrentAttributes as a thread-isolated attributes singleton that resets automatically before and after each request. In current Rails, the implementation goes through ActiveSupport::IsolatedExecutionState, which supports both :thread and :fiber isolation levels and defaults to :thread unless the application configures a different isolation level.5
config.active_support.isolation_level = :fiberThe precise idea is not “Current is Fiber-local” for every modern Rails application, though. The safer idea is that Current stores attributes in Rails’ isolated execution state, whose locality is configurable, and Rails clears that state through executor lifecycle hooks at the application boundary.
The Rails Executor is the wrapper around application code. The Rails Threading and Code Execution guide describes it as the boundary between framework code and your own code, with to_run callbacks before application execution and to_complete callbacks afterward. In current Rails, Active Support’s railtie wires execution context and current attributes into that lifecycle.6
# Conceptual shape from ActiveSupport::Railtie
app.executor.to_run do
ActiveSupport::ExecutionContext.push
end
app.executor.to_complete do
ActiveSupport::CurrentAttributes.clear_all
ActiveSupport::ExecutionContext.pop
endRails wraps ordinary web requests for you, and framework-managed job execution is also run through Rails execution wrappers. The place that hurts is custom concurrency:
Thread.new do
do_work_that_touches_models_and_current
endThe Rails guide is explicit about manual threads and Concurrent Ruby thread pools: wrap application code with the executor as soon as the thread begins running application work.
Thread.new do
Rails.application.executor.wrap do
do_work_that_touches_models_and_current
end
endThat wrapper is not only about Current.user. It also protects query cache lifetime, connection pool handling, autoload/reload safety, execution context, and other framework-owned state that should not bleed across application executions.
Failure Mode: Current.user as Invisible Job Input
This is the most common production bug because the code can look clean:
class ExportReport
def self.call(report_id)
report = Current.user.reports.find(report_id)
report.generate!
end
endThe controller path works:
ExportReport.call(params[:report_id])Then a background job reuses the same service:
class ExportReportJob < ApplicationJob
def perform(report_id)
ExportReport.call(report_id)
end
endThe service has hidden its real dependency. It needs an actor, but the method signature says it only needs a report ID. Inline execution, request specs, and happy-path manual tests may all pass because Current.user happens to be set by the surrounding request. A real worker exposes the truth: the service depends on the request-local state that the job doesn’t have.
The stronger interface makes the actor explicit:
class ExportReport
def self.call(report_id, actor:)
report = actor.reports.find(report_id)
report.generate!
end
end
class ExportReportJob < ApplicationJob
def perform(report_id, user_id)
actor = User.find(user_id)
ExportReport.call(report_id, actor:)
end
endThe lookup is also the authorization boundary: the job finds the report through the actor, not through a global report ID.
If a legacy subsystem genuinely expects Current, set it in the narrowest possible block rather than letting it become invisible job state:
class ExportReportJob < ApplicationJob
def perform(report_id, user_id)
actor = User.find(user_id)
Current.set(user: actor) do
ExportReport.call(report_id)
end
end
endThe block form shows the boundary and also gives Rails scope for restoring previous Current values.
Failure Mode: Tenant Context Derived From the Wrong Layer
Rails’ own CurrentAttributes example shows a user= setter that assigns account from user.account. That is reasonable for applications where every user has exactly one account and identity implies tenancy.
It becomes wrong in applications with account switching, delegated access, organization membership, impersonation, or admin consoles.
class Current < ActiveSupport::CurrentAttributes
attribute :user, :account
def user=(user)
super
self.account = user.account
end
endThe bug is not that Current.account exists; the bug is encoding a product assumption into the identity setter. In a multi-account system, the authenticated actor and the selected tenant are related but not identical. One answers “who is acting?” while the other answers “within which account boundary is this action authorized?”
Keep those assignments separate:
class ApplicationController < ActionController::Base
before_action :authenticate_user!
before_action :set_current_context
private
def set_current_context
account = current_user.accounts.find_by!(slug: params[:account_slug])
authorize_account_access!(current_user, account)
Current.user = current_user
Current.account = account
end
endTenant selection should come from the request, route, subdomain, account switcher, or an explicit policy, and then be authorized against the actor. Deriving it blindly from Current.user is how a convenient default turns into a cross-tenant data bug.
Failure Mode: Middleware Forces Identity Too Early
Middleware is the wrong place for casual identity access because middleware operates before the controller's intent is known.
This looks harmless in telemetry, rate limiting, or request logging code:
class RequestTelemetry
def initialize(app)
@app = app
end
def call(env)
user = env["warden"]&.authenticate(scope: :user)
tag_request(user_id: user&.id)
@app.call(env)
end
endThe problem is not only performance. authenticate is allowed to run strategies and invoke authentication failure behavior, so telemetry code can accidentally become authentication code. Even env[“warden”].user(:user) can materialize a database-backed user from the session. If this middleware runs on every request that reaches the Rails stack, the application has expanded “load the user when a controller needs it” into “load the user before routing for broad classes of requests.”
On some deployments, static files and health checks may never reach this middleware. On others, development asset requests, app-served files, engine routes, internal probes, or unauthenticated endpoints may pass through it. The exact blast radius depends on the stack. Still, the architectural smell is stable: a low-level Rack component is forcing high-level identity resolution without knowing whether the endpoint needs a user.
Prefer one of these:
# Use request-level identifiers that do not materialize a User record.
request = ActionDispatch::Request.new(env)
tag_request(
request_id: request.request_id,
ip: request.ip
)or, when authenticated identity is a deliberate requirement, constrain both placement and scope:
def call(env)
request = ActionDispatch::Request.new(env)
return @app.call(env) unless request.path.start_with?("/app/")
if warden = env["warden"]
user = warden.user(:user) # still a materialization point
tag_request(user_id: user&.id)
end
@app.call(env)
endThat code is not “free” because warden.user(:user) may deserialize from the session and hit the database. The improvement is that the middleware no longer invokes the full authentication flow for every request, and the path restriction makes the cost and behavior intentional.
A Debugging Checklist
When current_user behaves strangely, locate the failing code in the lifecycle before changing authentication logic.
Execution context: Is this code running inside a controller/view request, middleware, job, console, callback, or manual thread?
Session layer: Did the request pass through session middleware, and is
env[“rack.session”]available?Fetch path: Is the user coming from Warden’s per-request
@userscache, from session deserialization, or from a strategy?Current bridge: Is
Currentbeing read inside an executor-wrapped application execution?Hidden dependency: Is a service object hiding actor or tenant dependencies behind
Current?Actor vs. tenant: Are actor identity and tenant/account selection being resolved as separate concepts?
Most confusing authentication bugs become smaller once you name the layer that owns the value and the boundary that should have cleared, copied, or reconstructed it.
Where It Actually Lives
Between requests, the durable authentication reference lives in the session, usually as a compact serialized key carried by a cookie-backed or server-backed session store.
During Rack execution, Devise-backed applications find the authentication interface at env[“warden”], a request-local Warden::Proxy created by Warden middleware.
During authentication, Warden deserializes the scoped session key and memoizes the resulting user object in the proxy’s @users hash.
Inside the controller, Devise exposes current_user as a helper and memoizes it on the controller instance, commonly as @current_user.
Inside deeper application code, Current.user may hold a copy of that identity, but only if the controller or framework code explicitly placed it there for the current execution context.
After execution completes, Rails clears CurrentAttributes and unwinds the execution context through the Executor, so the thread- or fiber-local execution state does not become the next request’s identity.
The method feels like a single answer because Rails and Devise make the handoffs smooth. In production, it is a series of scoped owners with different lifetimes.
The debugging reflex is to treat strange current_user behavior as a lifecycle question, not just a helper return value question. Ask which layer reconstructed the identity, which scope cached it, which execution context copied it, and whether the code has crossed the boundary where “current” stopped meaning anything.
Warden’s manager middleware installs env[“warden”] = Proxy.new(env, self): Warden::Manager.
Warden’s session serializer reads and writes scoped session keys such as "warden.user.#{scope}.key” through env[“rack.session”]: Warden::SessionSerializer.
Warden’s proxy keeps a per-request @users hash, fetches session-backed users through the session serializer, and records fetched users with set_user(..., event: :fetch): Warden::Proxy.
Devise’s generated mapping helpers define current_#{mapping} in terms of warden.authenticate(scope: ...), memoized in an instance variable: Devise::Controllers::Helpers.
Rails’ isolated execution state supports thread and fiber isolation levels, with thread isolation as the default in the linked version: ActiveSupport::IsolatedExecutionState.
Active Support’s railtie wires execution context and CurrentAttributes.clear_all into executor callbacks: ActiveSupport::Railtie.
