Token Refresh Concurrency#
When multiple requests for the same user arrive simultaneously around the time a session token needs refreshing, oauth2-proxy can attempt to redeem the same refresh token more than once. Providers that enforce refresh token rotation (one-time-use tokens) will revoke the token after the first successful redemption, causing all subsequent concurrent attempts to fail with invalid_grant. This is the primary operational risk when --cookie-refresh is configured.
How the Lock Protocol Works#
The serialization logic lives in refreshSessionIfNeeded inside pkg/middleware/stored_session.go. The flow is:
- Acquire lock β each request calls
session.ObtainLockin a tight retry loop (10 ms sleep between attempts) . If the lock is not obtained within 5 seconds, the refresh attempt times out . - Re-load the session β once the lock is held, the session is reloaded from the store . If another goroutine already refreshed it,
needsRefreshreturns false and the caller skips the redundant provider call . - Refresh and save β only the single lock holder calls the provider and saves the updated session .
- Release lock β the lock is released in a
deferblock regardless of success or failure .
The lock is keyed per-session. The lock duration is hardcoded to 2 seconds , which is the maximum window the lock holder has to complete the refresh. Both timeout values have inline TODO comments noting they should be made user-configurable.
The Lock Interface and Implementations#
The Lock interface in pkg/apis/sessions/interfaces.go defines four operations: Obtain, Peek, Refresh, Release.
| Session Store | Lock Implementation | Effect |
|---|---|---|
| Redis | redis.Lock backed by bsm/redislock | Real distributed lock; key format is <session-key>.lock |
| Cookie | NoOpLock (auto-initialized if Lock field is nil) | All methods return nil; no mutual exclusion |
The cookie store never sets a Lock on the loaded SessionState , so ObtainLock silently initializes and uses NoOpLock . The locking middleware runs unconditionally, but with a cookie store it provides no protection.
For Redis, the lock is assigned during ticket-based session loading in ticket.loadSession via the initLockFunc callback, which is wired to redis.SessionStore.Lock().
Cookie Store: The Core Limitation#
With --session-store-type=cookie (the default), every concurrent request can and will attempt a provider refresh simultaneously. There is no server-side state to coordinate them. The repeated log lines:
[AuthSuccess] Refreshing session
[AuthSuccess] Refreshing session
[AuthSuccess] Refreshing session
are a diagnostic signal that this race is occurring . The issue is reproducible even with a single oauth2-proxy replica because the race originates from concurrent browser requests (e.g., page resources, API calls), not cross-replica coordination .
This limitation is acknowledged explicitly in the context of the HTTP session store feature proposal, which notes that --cookie-refresh combined with a non-Redis store has no concurrent refresh protection.
Mitigation#
Switch to --session-store-type=redis β this is the only current mitigation. The Redis-backed lock ensures exactly one goroutine per session performs the provider refresh, with all others transparently using the already-refreshed session data after the lock is released .
Workarounds that do not fully solve the problem:
- Increasing
--cookie-refreshreduces refresh frequency but doesn't eliminate the race window. - Disabling refresh token rotation on the provider removes the failure mode but sacrifices a security control.
Fatal vs. Non-Fatal Refresh Errors#
When a refresh does fail, isFatalRefreshError classifies the error. invalid_grant and invalid_client (per RFC 6749 Β§5.2) trigger immediate session invalidation and force re-authentication. Network errors and timeouts are treated as non-fatal β the existing session is preserved and validateSession determines if it is still usable .
Key Source Files#
| File | Purpose |
|---|---|
pkg/middleware/stored_session.go | Orchestrates lock acquire β reload β refresh β release |
pkg/sessions/redis/lock.go | Redis-backed Lock implementation |
pkg/apis/sessions/interfaces.go | Lock interface definition |
pkg/apis/sessions/lock.go | NoOpLock (cookie store fallback) |
pkg/sessions/cookie/session_store.go | Cookie store β no lock wiring |
pkg/sessions/persistence/ticket.go | Assigns Redis lock to session on load |