RefreshCoordinator
Supabase::Rails::Web::RefreshCoordinator — the per-worker mutex pool that serializes concurrent token refreshes keyed by SHA256(refresh_token), with refcounted entry cleanup.
Supabase::Rails::Web::RefreshCoordinator is the in-process mutex pool that prevents two concurrent threads in the same Puma worker from both calling auth.refresh_session for the same Supabase session. The pool is keyed by SHA256(refresh_token) — two requests carrying the same cookie cooperate on a single outbound call; two requests carrying different cookies run independently.
You almost never call this module directly — CookieCredentialStrategy wraps every refresh attempt in RefreshCoordinator.synchronize before deciding whether to actually issue the upstream call. The module is documented here because (a) the per-worker scope has visible behaviour during clustered deploys, (b) the refcounted cleanup is what keeps the hash from growing unboundedly in long-lived workers, and (c) the reset! / entry_count API exists for tests that need deterministic mutex state.
# Pseudocode for what CookieCredentialStrategy runs.
outcome = Supabase::Rails::Web::RefreshCoordinator.synchronize(refresh_token) do
current = read_session_cookie
if current && !near_expiry?(current)
current # someone else already refreshed inside the mutex; reuse
else
auth_client.refresh_session(refresh_token)
end
endWhy a mutex pool
The :web-mode cookie is the only credential the browser carries, and Supabase's refresh tokens rotate: a successful refresh_session returns a new refresh token and invalidates the old one. Two threads that hand the same old refresh token to Supabase at the same time will see one succeed and the other fail with a 4xx — even though both requests are perfectly legitimate. The losing thread would clear the cookie, signing the user out mid-page-load.
RefreshCoordinator.synchronize is the fix: while one thread holds the mutex, every other thread for the same refresh_token blocks. Inside the mutex, the strategy re-reads the cookie — by the time the second waiter wakes, the first has already written a fresh session, so the second reuses it instead of issuing a second refresh call. From the user's perspective, both requests are served with the new tokens; from Supabase's perspective, exactly one rotation happened.
Per-worker scope only
The mutex pool is a per-process Hash. On Puma with multiple workers, two simultaneous requests routed to different workers can both refresh — each worker has its own mutex. In practice browsers serialize cookie-bearing requests over HTTP/2 (and almost always over HTTP/1.1), so this race is observable only with hand-crafted concurrent clients. The PRD treats it as acceptable for v0.2 and revisitable if telemetry shows refresh contention.
Key derivation — SHA256(refresh_token)
Refresh tokens are kept out of memory beyond the request that carries them. The mutex pool keys by Digest::SHA256.hexdigest(refresh_token.to_s) instead of the raw token so:
- A heap dump or crash log that captures
@entriesshows hex digests, not credentials. - Two
RefreshCoordinator.synchronize(rt)calls in different parts of the codebase share a mutex even if one passes aStringand the other passes aString.dup. - Coercing
nilor an unexpected type via.to_skeys reliably (""digests to a fixed hex string, which is fine — anilrefresh token shouldn't reach this code path, but defensive coercion avoids aNoMethodError).
The digest is a one-way mapping; there is no way to recover the refresh token from @entries. This matters during incident debugging — operators can safely log entry_count or even iterate keys to confirm the pool is doing what it should.
RefreshCoordinator.synchronize(refresh_token, &block)
| Returns | Raises |
|---|---|
| The block's return value | Whatever the block raises (the mutex is released on raise) |
Yield the block while holding the per-token mutex. The pool entry is acquired before the block, released after — even if the block raises. The return value is the block's return value, unmodified.
result = Supabase::Rails::Web::RefreshCoordinator.synchronize(refresh_token) do
# exclusive section — only one thread per refresh_token enters
perform_refresh(refresh_token)
endReentrancy
Mutex#synchronize is not reentrant — calling RefreshCoordinator.synchronize(rt) from inside another RefreshCoordinator.synchronize(rt) for the same refresh token (and on the same thread) deadlocks the thread on itself. In practice, the only caller is CookieCredentialStrategy#refresh_or_clear, which never re-enters; this is documented for hosts writing custom middleware.
Exception safety
The begin/ensure wrapper guarantees checkin(key) runs even if the block raises. The refcount is decremented and the entry dropped when it hits zero, so a block that raises does not leak its mutex slot. The exception itself propagates to the caller (CookieCredentialStrategy's rescue StandardError clauses then classify it as :transient).
Refcounted entry cleanup
The pool is a Hash keyed by digest. Each entry is { mutex: Mutex.new, refs: Integer }. The refcount tracks concurrent holders so the entry can be dropped when no thread is holding it:
@entries = {
"9f86d0..." => { mutex: #<Mutex>, refs: 2 }, # two threads currently refreshing this session
"84983e..." => { mutex: #<Mutex>, refs: 1 }
}| Step | What happens |
|---|---|
checkout(key) (called before synchronize) | Under @entries_mutex, find-or-create the entry, refs += 1, return the Mutex. |
| Block executes | The returned mutex is .synchronized. |
checkin(key) (called in ensure) | Under @entries_mutex, refs -= 1. If refs <= 0, @entries.delete(key). |
The two-mutex design (@entries_mutex guarding the Hash, per-entry mutex guarding the critical section) keeps the entry-lookup path short — the Hash is only locked for the find-or-create-and-incref window, not for the duration of the refresh.
Without the refcount, the pool would grow with every unique refresh token ever observed by the worker — over weeks of uptime that's a memory leak. With it, the pool's size tracks the current concurrency: typically 0 in steady state, briefly 1 during a refresh, occasionally 2+ during a coordinated reload.
RefreshCoordinator.entry_count
| Returns | Visibility |
|---|---|
Integer — current count of live entries | Public, intended for tests and introspection. |
Snapshot the number of live mutex entries in this worker. Mostly useful for tests that assert the pool was cleaned up after a refresh — a non-zero count after every test thread joined indicates a leaked checkin, which would mean a bug in the refcount accounting.
it "drops the entry when the refresh completes" do
expect {
described_class.synchronize("rt") { "ok" }
}.not_to change(described_class, :entry_count)
endThe reading is under @entries_mutex so it sees a consistent count even with concurrent refreshes in flight.
RefreshCoordinator.reset!
| Returns | Visibility |
|---|---|
nil (the internal Hash#clear result) | Public but underscore-spelled reset! — test-only escape hatch. |
Forcibly drop every entry. Intended for tests where a previous example may have leaked a synchronize block (e.g. via a forced thread kill) and the next example needs a clean pool. Calling this while another thread is inside synchronize is safe — the held mutex object still exists (Ruby's GC keeps it alive while the holding thread references it), but new checkout calls allocate a fresh entry.
RSpec.configure do |config|
config.before(:each) { Supabase::Rails::Web::RefreshCoordinator.reset! }
endThere is no production use case for reset! — the refcounting handles steady-state cleanup. Calling it in a running app would not break anything (worst case, two concurrent refreshes for the same token would briefly race), but there is no reason to.
Worked example — two concurrent requests
Two browser tabs share the same cookie. Both fire GET /dashboard at the moment expires_at <= now + 10s.
Thread A Thread B
│ │
│ CookieCredentialStrategy#call │ CookieCredentialStrategy#call
│ refresh_or_clear(session) │ refresh_or_clear(session)
│ │
│ RefreshCoordinator.synchronize(rt) │ RefreshCoordinator.synchronize(rt)
│ checkout(key) refs: 0 → 1 │ checkout(key) refs: 1 → 2
│ mutex.synchronize ─► acquired │ mutex.synchronize ─► waiting...
│ read cookie → stale │
│ auth.refresh_session(rt) │
│ ─► new Session │
│ write_cookie(new_session) │
│ return new_session │
│ mutex.synchronize ─► released │ mutex.synchronize ─► acquired
│ checkin(key) refs: 2 → 1 │ read cookie → fresh enough!
│ │ return existing session
│ │ mutex.synchronize ─► released
│ │ checkin(key) refs: 1 → 0
│ │ @entries.delete(key)
│ │
│ apply_outcome(new_session) │ apply_outcome(existing_session)
│ ─► user_context │ ─► user_contextThread B's second cookie read is the load-bearing optimization. Without it, Thread B would wait for the mutex, then redundantly call auth.refresh_session(rt') with the new refresh token Thread A just wrote — at best a wasted network round-trip, at worst a 4xx if the upstream considers same-second double rotation an error. The re-read makes Thread B reuse Thread A's work.
What this module does not do
- It does not own retry logic. A failed refresh raises;
CookieCredentialStrategydecides whether to clear the cookie or surfaceREFRESH_UNAVAILABLE. The coordinator's job ends at "exactly one thread per token runs the block". - It does not span workers. Two requests routed to different Puma workers each acquire their own worker-local mutex and may both refresh. See the per-worker scope callout.
- It does not enforce a timeout.
Mutex#synchronizeblocks indefinitely. The upstreamSupabase::Auth::Client#refresh_sessionhas its own connect/read timeouts (defaults fromNet::HTTP), so a hung Supabase Auth backend bounds the wait without the coordinator needing its own timer. - It does not log. Logging happens in
CookieCredentialStrategy— the coordinator is a pure synchronization primitive.
See also
- Web mode overview — where the refresh phase fits into the request lifecycle.
CookieCredentialStrategy— the only production caller ofsynchronize, and the place where refresh outcomes are classified.AuthClientFactory— builds the per-requestSupabase::Auth::Clientthatrefresh_sessionis called on inside the mutex.- Session store — the encrypted-cookie wrapper that the inside-mutex re-read calls.
CookieCredentialStrategy
Supabase::Rails::Web::CookieCredentialStrategy — the per-request strategy that reads the encrypted session cookie, validates expiry, refreshes inline, and routes the outcome into anonymous, user, or 503 contexts.
RedirectValidator
Supabase::Rails::Web::RedirectValidator — validates redirect targets (e.g. ?redirect_to=) against config.supabase.allowed_redirect_origins to prevent open-redirect vulnerabilities.