Skip to content

fix(session): repopulate secondaryStorage after database fallback in findSession - #11230

Open
breken-ai wants to merge 4 commits into
better-auth:mainfrom
breken-ai:fix-11218-push
Open

fix(session): repopulate secondaryStorage after database fallback in findSession#11230
breken-ai wants to merge 4 commits into
better-auth:mainfrom
breken-ai:fix-11218-push

Conversation

@breken-ai

@breken-ai breken-ai commented Sep 9, 2026

Copy link
Copy Markdown

Fixes #11218.

When a session was missing from secondaryStorage (TTL expiry, eviction, flush) but still valid in the database, findSession's database fallback returned it without writing it back - so every subsequent request for that session kept hitting the primary database until the next login.

The fallback now repopulates secondaryStorage with the remaining TTL (same shape as createSession's mirror). It also restores the token in the active-sessions index: deleteUserSessions sweeps cached sessions through that index, so a write-back without it would have let a repopulated session survive revoke-all until cache expiry. The existing preserveSessionInDatabase early return is untouched, so revoked-and-preserved rows still can't be restored.

Tests: two cases in secondary-storage.test.ts - cache/index repair after a simulated flush, and revoke-all still sweeping a repopulated session. Both fail on main; the full file plus internal-adapter and session-api suites pass with the fix.

Built by breken, your AI support engineer - breken.ai - this one's on us.


Summary by cubic

Fixes #11218. When a session is missing from secondary storage (TTL expiry, eviction, flush) but still valid in the database, findSession now writes it back with its remaining TTL and restores the active-sessions index entry, so repeat reads no longer hit the primary DB and revoke-all still sweeps the session.

  • Cache reads and repair writes are best-effort when a database fallback exists: an outage is logged and treated as a cache miss, and repair failure serves the authoritative database session instead of failing the request.
  • Without storeSessionInDatabase, a secondary-storage outage surfaces as a request error instead of being treated as an invalid session that could clear valid cookies.
  • Known limitation: a concurrent revoke can race the write-back and leave the token accepted until its session TTL expires; fully closing that needs atomic cache+database coordination.
  • preserveSessionInDatabase early return is unchanged, so revoked-and-preserved rows cannot be resurrected.

Written for commit f34ee67. Summary will update on new commits.

Review in cubic


Follow-up (review feedback, 2026-09-09):

  • The database-fallback cache repair is now best-effort: the entire repair block is try/wrapped, so a secondary-storage failure (eviction + unreachable Redis) cannot reject out of findSession after the database has already returned the authoritative session - it logs the error and serves the DB result. Matching test: a throwing secondary storage cannot turn /get-session into a 500.
  • Known limitation (disclosed): the repair has a residual check-then-act window against a concurrent revoke (revocation deletes the database row in flight while this repair repopulates the cache, in which case the token can remain accepted until its session TTL expires). Fully closing this requires atomic cache+database coordination (e.g. a Lua-scripted revoke on Redis, or versioned tokens) - deliberately not shipped as a re-read half-guard that narrows but does not close the race. Happy to pick that design up as a follow-up if wanted.

@breken-ai
breken-ai requested a review from a team as a code owner September 9, 2026 10:09
@breken-ai
breken-ai requested review from Bekacru and removed request for a team September 9, 2026 10:09
@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown

@swetabhsmn is attempting to deploy a commit to the better-auth Team on Vercel.

A member of the Team first needs to authorize it.

@better-release better-release Bot added the database Database layer, all adapters, schema, migrations label Sep 9, 2026
@vercel-security-reviewer

Copy link
Copy Markdown

Security review details

@greptile-apps

greptile-apps Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR repairs secondary-storage session entries after a successful database fallback and restores the corresponding active-session index entry.

  • Makes cache reads and repairs best-effort only when database fallback is available.
  • Preserves storage errors when secondary storage is authoritative.
  • Adds coverage for cache repair, revoke-all cleanup, and secondary-storage outages.

Confidence Score: 4/5

The PR is not yet safe to merge because concurrent repairs can still lose active-session index entries and allow a revoked cached session to remain usable until expiry.

The previously reported concurrent-repair issue remains unresolved: two database fallbacks for sessions belonging to the same user can read the same active-session list and overwrite one another, leaving one cached token outside revoke-all’s sweep. The cache-outage finding is fixed because failures are swallowed only when the database fallback can run. breken-ai accepted the separate concurrent-revocation repair race as a bounded known limitation requiring atomic database/cache coordination, and that thread was resolved on this basis.

Files Needing Attention: packages/better-auth/src/db/internal-adapter.ts

Reviews (4): Last reviewed commit: "fix(session): re-throw storage outages w..." | Re-trigger Greptile

Comment thread packages/better-auth/src/db/internal-adapter.ts Outdated
Comment thread packages/better-auth/src/db/internal-adapter.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/better-auth/src/db/internal-adapter.ts">

<violation number="1" location="packages/better-auth/src/db/internal-adapter.ts:678">
P1: When two sessions for the same user miss secondary storage concurrently, both repairs read the same active-sessions list and the later write drops the other token. Serialize or atomically merge index repairs; otherwise `listSessions` and revoke-all can miss a still-cached session until its TTL expires.</violation>

<violation number="2" location="packages/better-auth/src/db/internal-adapter.ts:686">
P1: When revoke-all races a database fallback, the fallback can write the token after `deleteUserSessions` commits and its queued cache sweep runs. Subsequent cache hits then keep the revoked token usable until TTL expiry; coordinate write-back with revocation or revalidate before publishing the cache entry.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

const sessionTTL = getTTLSeconds(parsedSession.expiresAt, now);
if (sessionTTL > 0) {
const activeSessionsKey = `active-sessions-${parsedUser.id}`;
const currentList = await secondaryStorage.get(activeSessionsKey);

@cubic-dev-ai cubic-dev-ai Bot Sep 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When two sessions for the same user miss secondary storage concurrently, both repairs read the same active-sessions list and the later write drops the other token. Serialize or atomically merge index repairs; otherwise listSessions and revoke-all can miss a still-cached session until its TTL expires.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/better-auth/src/db/internal-adapter.ts, line 678:

<comment>When two sessions for the same user miss secondary storage concurrently, both repairs read the same active-sessions list and the later write drops the other token. Serialize or atomically merge index repairs; otherwise `listSessions` and revoke-all can miss a still-cached session until its TTL expires.</comment>

<file context>
@@ -663,6 +663,48 @@ export const createInternalAdapter = (
+				const sessionTTL = getTTLSeconds(parsedSession.expiresAt, now);
+				if (sessionTTL > 0) {
+					const activeSessionsKey = `active-sessions-${parsedUser.id}`;
+					const currentList = await secondaryStorage.get(activeSessionsKey);
+					const list =
+						safeJSONParse<{ token: string; expiresAt: number }[]>(currentList) ||
</file context>
Fix with cubic

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, and the same family as the revoke race in the greptile thread above: fully closing it needs atomic coordination between the cache index and the database (a Lua-merged index write on Redis, or versioned entries), which is a design change beyond this fix - it is disclosed as a known limitation in the PR body rather than half-guarded. The practical exposure is bounded: both repairs write the same session back, so the lost update only drops a sibling token from the active-sessions index (listSessions / revoke-all can miss that still-valid session until its TTL), while the dropped session's own key is repopulated on its next request. Happy to take the atomic design as a follow-up if you want it.

(s) => s.expiresAt > now && s.token !== token,
);
filtered.push({
token,

@cubic-dev-ai cubic-dev-ai Bot Sep 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When revoke-all races a database fallback, the fallback can write the token after deleteUserSessions commits and its queued cache sweep runs. Subsequent cache hits then keep the revoked token usable until TTL expiry; coordinate write-back with revocation or revalidate before publishing the cache entry.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/better-auth/src/db/internal-adapter.ts, line 686:

<comment>When revoke-all races a database fallback, the fallback can write the token after `deleteUserSessions` commits and its queued cache sweep runs. Subsequent cache hits then keep the revoked token usable until TTL expiry; coordinate write-back with revocation or revalidate before publishing the cache entry.</comment>

<file context>
@@ -663,6 +663,48 @@ export const createInternalAdapter = (
+						(s) => s.expiresAt > now && s.token !== token,
+					);
+					filtered.push({
+						token,
+						expiresAt: parsedSession.expiresAt.getTime(),
+					});
</file context>
Fix with cubic

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same answer as the greptile thread above: agreed the write-back can publish after a revoke-all sweep, and we deliberately did not ship a re-read half-guard - it shrinks the window without closing it and reads as closed in review. Fully closing it needs atomic cache/database coordination, disclosed as a known limitation in the PR body. Bounded in practice: requires storeSessionInDatabase with a live DB row, a concurrent revoke-all in the exact interleaving, and the resurrected entry carries only its remaining session TTL. Happy to pick the atomic design up as a follow-up.

Comment thread packages/better-auth/src/db/internal-adapter.ts Outdated
Addressing review feedback on better-auth#11230: the newly awaited repair writes
meant a secondary-storage failure (eviction + unreachable Redis) could
reject out of findSession after the database had already returned a
valid session, turning /get-session into an internal error. The whole
repair block is now try/wrapped and logged, matching the existing
deferred-mirror error posture.

Known limitation (disclosed, following review discussion): the repair
still has a theoretical check-then-act window against a CONCURRENT
revoke (revoke deletes the database row in flight while this repair
repopulates the cache, so the token could remain accepted until its
session TTL expires). Fully closing that window requires atomic
cache+database primitives (e.g. a Lua-scripted revoke on Redis or
versioned tokens), which is beyond the scope of this repair fix.
Added a best-effort test: a throwing secondary storage cannot break
the database fallback on /get-session.
Comment on lines +686 to +715
const currentList =
await secondaryStorage.get(activeSessionsKey);
const list =
safeJSONParse<{ token: string; expiresAt: number }[]>(
currentList,
) || [];
const filtered = list.filter(
(s) => s.expiresAt > now && s.token !== token,
);
filtered.push({
token,
expiresAt: parsedSession.expiresAt.getTime(),
});
filtered.sort((a, b) => a.expiresAt - b.expiresAt);
const furthestSessionTTL = getTTLSeconds(
filtered[filtered.length - 1]!.expiresAt,
now,
);
await Promise.all([
secondaryStorage.set(
token,
JSON.stringify({ session: parsedSession, user: parsedUser }),
sessionTTL,
),
secondaryStorage.set(
activeSessionsKey,
JSON.stringify(filtered),
furthestSessionTTL,
),
]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Concurrent repairs lose sessions

When two sessions for the same user concurrently fall back to the database, both repairs can read the same active-session index and then overwrite it with only their own token. The losing token remains cached but disappears from the index, so revoke-all deletes its database row without deleting the cached session. Later cache-hit lookups continue authenticating that session until its TTL expires.

How this was verified: Revocation deletes cached tokens solely through the active-session index, while cache-hit session lookup accepts the cached value without rechecking the database.

Knowledge Base Used: Distributed storage and caching

Fix in Cursor Fix in Codex Fix in Claude Code

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 existing issue remains and no new issues found across 2 files (changes from recent commits).

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

Fix all with cubic | Re-trigger cubic

Comment thread packages/better-auth/src/db/internal-adapter.ts
Comment thread packages/better-auth/src/db/secondary-storage.test.ts

@bytaesu bytaesu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check the comments, follow up where needed, and resolve them if there are no issues.

The best-effort hardening covered the cache-repair writes, but the initial
secondaryStorage.get(token) at the top of findSession was still unguarded:
a storage outage rejected the request before the database fallback could
run. Treat a failed read as a cache miss (logged) and fall through to the
database; without storeSessionInDatabase the miss path still returns null
as before.

Also restructure the outage test so it can reach the path it covers:
session creation touches the cache synchronously, so sign-in must run
against the healthy store; the outage now flips on only for the read path.
@breken-ai

Copy link
Copy Markdown
Author

@bytaesu Follow-up done across all review threads, per your request:

Addressed in e40449c (pushed just now):

  • cubic's P1 on the unguarded initial secondaryStorage.get(token): now wrapped - an outage is logged and treated as a cache miss, so the request falls through to the database fallback instead of 500ing. Behavior without storeSessionInDatabase is unchanged (miss path still returns null).
  • cubic's P2 on the outage test: correct - the old setup threw during sign-in (session creation mirrors to the cache synchronously) and never reached the read path. The flaky storage now takes an outage predicate: sign-in runs healthy, the outage flips on only for the getSession read, so the test drives the guarded-read + database-fallback + best-effort-repair chain it claims to.

Answered with rationale (no code change): the two cubic P1s on repair-write races (concurrent repairs lost-update on the index; revoke-all racing the write-back) and greptile's earlier revoke-restore P1. All three need atomic cache/database coordination (Lua-merged writes or versioned entries) - a design change beyond this fix, disclosed in the PR body's known-limitation note, with the exposure bounds in each reply. Happy to pick that design up as a follow-up.

Already marked addressed by the bot: cubic's P2 on the non-array safeJSONParse result (fixed in ca8f5b5).

Two honesty notes: I don't have permission to resolve conversations on this repo, so each thread carries a reply instead - feel free to resolve as you re-review. And we could not run vitest locally from this environment; the test restructure is verified by static trace, CI runs the suite.

Comment thread packages/better-auth/src/db/internal-adapter.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread packages/better-auth/src/db/internal-adapter.ts
e40449c guarded the initial secondary-storage read unconditionally: with
storeSessionInDatabase disabled (the default) a failed read fell through
to return null, so a transient outage was treated as an invalid session
and /get-session could clear valid cookies. Only treat a read failure as
a cache miss when the database fallback can actually serve the session
(storeSessionInDatabase enabled and preserveSessionInDatabase off);
otherwise re-throw so the outage surfaces as a request error with the
session cookie left intact. Adds a test for the no-database-fallback
outage case.

Not run locally (no dependency install in this environment); vitest
secondary-storage suite should cover both outage modes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

database Database layer, all adapters, schema, migrations

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Repopulate secondaryStorage after database fallback in findSession

3 participants