Session Management#
Session management in better-auth encompasses three orthogonal concerns: deployment mode (stateful with a durable store vs. stateless cookie-only), session validation strategy (cookie-cache bypass vs. DB-backed lookup), and runtime initialization of the AsyncLocalStorage context used to thread session state through request handlers.
The central function for resolving a session from an incoming request is getSessionFromCtx in packages/better-auth/src/api/routes/session.ts. Whether it hits the cookie cache or the database depends entirely on the deployment mode detected at runtime.
Stateful vs. Stateless Deployment Modes#
The single source of truth for deployment-mode detection is hasServerSessionStore() in packages/better-auth/src/context/store-capabilities.ts:
hasServerSessionStore(options) → !!options.database || !!options.secondaryStorage
A deployment is stateful if either a database or secondaryStorage is configured; otherwise the session lives entirely in the signed session cookie. Note that hasServerAccountStore() (used to decide whether to bind account cookies to session users) requires database only — secondary storage alone is not enough .
The isStateful(ctx) helper (imported in routes from session.ts) is used throughout token and session routes to gate cache-bypass and fail-closed behavior.
Session Validation: Cookie Cache vs. Database-Backed#
Token routes bypass the cookie cache in stateful deployments#
/get-access-token, /refresh-token, and /account-info all resolve their session via resolveUserId, which calls getSessionFromCtx with disableCookieCache: isStateful(ctx) . This ensures a server-side session revocation takes effect immediately rather than waiting for the cached cookie to expire. In DB-less deployments, the cookie remains authoritative and the cache is not bypassed.
getSessionFromCtx cannot be weakened by query params#
When disableCookieCache or disableRefresh is forced by a caller, the merge logic ORs the config value with the request query param , preventing a crafted ?disableCookieCache= request from overriding a forced strict validation setting.
/update-session fails closed on revoked sessions#
After attempting to update the session row, if no row was updated (updateSession returns null) and the deployment is stateful, /update-session deletes the session cookie and throws UNAUTHORIZED . This prevents re-minting a revoked session from stale cookie data. Stateless deployments are unaffected — the cookie is the only state, so a missing DB row is normal.
Stateless Cross-Instance Account Resolution#
Problem (PR #9979): In stateless OAuth deployments, sign-in and subsequent requests can land on different server instances. The account cookie's userId is bound to the instance that performed sign-in; the session cookie resolved by a different instance carries a different local userId. With no durable account row, the strict equality check fails, breaking /get-access-token, /refresh-token, /account-info, and session-cache refresh.
Fix: shouldBindAccountCookieToSessionUser() gates strict user-id binding on hasServerAccountStore() (database only). Without a database, account selection falls back to provider identity (and optional provider account ID). Additionally, setCookieCache no longer clears the account cookie when userId mismatches in stateless mode, preventing spurious "Account not found" errors on session refresh.
AsyncLocalStorage Concurrency Race on Cold Isolates#
Issue #10300 — affects v1.6.x including v1.6.23.
Root cause#
ensureAsyncStorage in packages/core/src/context/request-state.ts uses an unguarded check-then-await-then-assign pattern:
- Caller A and Caller B both see
requestStateAsyncStorage == nulland enter theifblock. - A
awaitsgetAsyncLocalStorage(), yielding to the event loop. - B completes its
await, assigns a newAsyncLocalStorageinstance to the global, overwriting the instance A is already running inside. - When A's handler resumes,
getCurrentRequestState()queries the new instance — which has no store — and throws"No request state found".
The same unguarded pattern exists in all three async storage initializers: requestStateAsyncStorage , endpointContextAsyncStorage , and adapterAsyncStorage — all need the same fix.
Observable symptoms#
- Intermittent
INTERNAL_SERVER_ERROR: No request state foundon cold isolate startup. getSession()returnsnullfor a logged-in user → spurious 401s downstream.- Self-heals after the first burst (the race only occurs during first-init).
- Most commonly triggered by SSR frameworks fanning out parallel
auth.api.getSession()calls per render on Cloudflare Workers or similar edge runtimes.
Fix (PR #9862 — open as of 2026-07-02)#
Promise memoization + idempotent ??= assignment: all concurrent callers await the same init promise, and the ??= operator ensures a completed instance is never overwritten. A regression test races 32 concurrent requests through initialization.
Workaround until merged: Ensure the first auth.api.getSession() call on a cold isolate completes before dispatching concurrent requests (e.g., serialize the first call in a warmup handler).
Key Files & References#
| File | Purpose |
|---|---|
packages/better-auth/src/api/routes/session.ts | getSessionFromCtx, getSession endpoint, isStateful helper |
packages/better-auth/src/api/routes/account.ts | resolveUserId — cache-bypass for token routes |
packages/better-auth/src/api/routes/update-session.ts | Fail-closed revocation logic |
packages/better-auth/src/context/store-capabilities.ts | hasServerSessionStore, shouldBindAccountCookieToSessionUser |
packages/core/src/context/request-state.ts | ensureAsyncStorage, runWithRequestState, getCurrentRequestState |
packages/core/src/context/endpoint-context.ts | endpointContextAsyncStorage initializer (same race pattern) |
packages/core/src/context/transaction.ts | adapterAsyncStorage initializer (same race pattern) |
Related PRs:
- PR #9967 — Honor server-side session deletion in
/update-sessionand token routes (merged 2026-06-10) - PR #9979 — Stateless account cookie resolution across instances (merged 2026-06-11)
- PR #9862 — AsyncLocalStorage concurrency fix (open as of 2026-07-02)
- Issue #10300 — AsyncLocalStorage race report and analysis