Skip to content

fix(auth): prevent infinite session loop with non-reactive provider - #481

Merged
leoisadev1 merged 1 commit into
mainfrom
fix/auth-session-spam
Jan 3, 2026
Merged

fix(auth): prevent infinite session loop with non-reactive provider#481
leoisadev1 merged 1 commit into
mainfrom
fix/auth-session-spam

Conversation

@leoisadev1

Copy link
Copy Markdown
Member

Summary

CRITICAL FIX: Stops the Better Auth session spam (~4 req/sec to /api/auth/get-session) that was causing millions of Convex function calls and making the app unusable.

Root Cause

The crossDomainClient plugin from @convex-dev/better-auth calls store.notify("$sessionSignal") on EVERY response with set-better-auth-cookie header. This triggers useSession to refetch, creating an infinite loop:

  1. Session fetched
  2. Response has set-better-auth-cookie header
  3. Plugin stores cookie and notifies $sessionSignal
  4. useSession reacts and refetches
  5. Loop continues endlessly

Fix

  1. Deduplicating storage: Only writes when value actually changes, preventing unnecessary signal notifications
  2. StableAuthProvider: Custom provider that fetches session ONCE on mount, caching the result
  3. useStableConvexAuth: Non-reactive Convex auth hook that doesn't trigger on $sessionSignal
  4. Replaced ConvexBetterAuthProvider: With our custom implementation that breaks the loop

Changes

  • apps/web/src/lib/auth-client.tsx (renamed from .ts): Added deduplicating storage, StableAuthProvider, non-reactive useAuth
  • apps/web/src/providers/index.tsx: Replaced ConvexBetterAuthProvider with StableAuthProvider + ConvexProviderWithAuth

Testing

  • Build passes
  • Convex deployed successfully
  • Session is fetched once on mount, not repeatedly

Root cause: crossDomainClient plugin notifies $sessionSignal on EVERY
response with set-better-auth-cookie header, causing useSession to refetch
endlessly.

Fix:
- Add deduplicating storage that only writes when value actually changes
- Replace ConvexBetterAuthProvider with custom StableAuthProvider
- Use one-time session fetch instead of reactive useSession hook
- Implement custom useStableConvexAuth for Convex token management
@railway-app

railway-app Bot commented Jan 3, 2026

Copy link
Copy Markdown

🚅 Deployed to the openchat-pr-481 environment in OpenChat

Service Status Web Updated (UTC)
web 🕒 Building (View Logs) Web Jan 3, 2026 at 2:34 am

@railway-app
railway-app Bot temporarily deployed to OpenChat / openchat-pr-481 January 3, 2026 02:34 Destroyed
@github-actions

github-actions Bot commented Jan 3, 2026

Copy link
Copy Markdown
Contributor

🚀 Preview Deployment Ready

Environment URL
Frontend https://web-openchat-pr-481.up.railway.app
Convex Dashboard Dashboard

Convex Preview Backend

  • Cloud URL: https://wandering-seahorse-835.convex.cloud
  • Site URL: https://wandering-seahorse-835.convex.site

🤖 Deployed automatically by GitHub Actions

@leoisadev1
leoisadev1 merged commit 7db1d5c into main Jan 3, 2026
5 checks passed
@leoisadev1
leoisadev1 deleted the fix/auth-session-spam branch January 3, 2026 02:36
@greptile-apps

greptile-apps Bot commented Jan 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a critical infinite loop bug causing ~4 req/sec to /api/auth/get-session from Better Auth's crossDomainClient plugin. The plugin notifies $sessionSignal on every response with set-better-auth-cookie header, triggering reactive hooks to refetch endlessly.

Key changes:

  • Added deduplicating storage that only writes when values change, preventing unnecessary signal notifications
  • Created StableAuthProvider and useStableConvexAuth that fetch session once on mount instead of reacting to signals
  • Replaced ConvexBetterAuthProvider with the custom non-reactive implementation

Issues found:

  • fetchedRef in useStableConvexAuth uses plain object instead of useRef (line 34 in index.tsx) - will cause duplicate fetches
  • fetchAccessToken caches stale tokens and won't refresh on expiry (line 57-67 in index.tsx) - could cause auth failures
  • disableCache option on crossDomainClient needs verification (line 105-109 in auth-client.tsx) - may not exist

The approach is sound and addresses the root cause, but the two bugs in useStableConvexAuth need fixing before merge.

Confidence Score: 3/5

  • This PR fixes a critical infinite loop but introduces two bugs that will cause auth failures in production
  • The core solution is architecturally sound and correctly addresses the root cause (deduplicating storage + non-reactive providers). However, two logic bugs in useStableConvexAuth will cause production issues: (1) plain object ref instead of useRef causes duplicate fetches, (2) stale token caching prevents token refresh on expiry. These must be fixed before merge.
  • apps/web/src/providers/index.tsx - contains both critical bugs in useStableConvexAuth hook

Important Files Changed

Filename Overview
apps/web/src/lib/auth-client.tsx Added deduplicating storage and non-reactive auth provider to fix infinite session loop. One unverified option (disableCache).
apps/web/src/providers/index.tsx Replaced reactive provider with stable auth. Two bugs: wrong ref initialization and stale token caching.

Sequence Diagram

sequenceDiagram
    participant User
    participant Component
    participant StableAuthProvider
    participant AuthHook
    participant AuthClient
    participant Storage
    participant ConvexProvider
    participant Backend

    Note over User,Backend: Initial Mount
    User->>Component: Load App
    Component->>StableAuthProvider: Mount
    activate StableAuthProvider
    StableAuthProvider->>AuthClient: getSession once
    AuthClient->>Backend: Fetch session
    Backend-->>AuthClient: Session data
    AuthClient->>Storage: setItem check
    Note over Storage: Value unchanged skip write
    AuthClient-->>StableAuthProvider: Session result
    StableAuthProvider->>StableAuthProvider: Update state
    deactivate StableAuthProvider

    Component->>AuthHook: Mount
    activate AuthHook
    AuthHook->>AuthClient: getSession once
    AuthClient->>Backend: Fetch session
    Backend-->>AuthClient: Session response
    AuthClient->>Storage: setItem check
    Note over Storage: Same value no write
    AuthClient-->>AuthHook: Session data
    AuthHook->>AuthClient: Get access details
    AuthClient->>Backend: Fetch details
    Backend-->>AuthClient: Details response
    AuthClient-->>AuthHook: Details result
    AuthHook->>AuthHook: Update state
    deactivate AuthHook

    Note over User,Backend: Convex Query
    Component->>ConvexProvider: useQuery
    ConvexProvider->>AuthHook: fetchAccessToken
    AuthHook-->>ConvexProvider: Return cached value
    ConvexProvider->>Backend: Convex query
    Backend-->>ConvexProvider: Query result
    ConvexProvider-->>Component: Data

    Note over Component,Storage: Solution prevents infinite loop
Loading

@greptile-apps greptile-apps 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.

3 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const fetchedRef = { current: false };

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.

logic: fetchedRef should be useRef(false) instead of a plain object

Suggested change
const fetchedRef = { current: false };
const fetchedRef = useRef(false);

Comment on lines +105 to +109
crossDomainClient({
storage: deduplicatingStorage,
// Disable local session cache - we manage caching ourselves
disableCache: true,
}),

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.

style: disableCache option may not exist on crossDomainClient - verify this in Better Auth docs to avoid runtime errors. Does the crossDomainClient plugin actually support a disableCache option?

Comment on lines +57 to +67
const fetchAccessToken = useCallback(async () => {
if (token) return token;
try {
const result = await authClient.convex.token();
const newToken = result.data?.token || null;
setToken(newToken);
return newToken;
} catch {
return null;
}
}, [token]);

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.

logic: early return with cached token doesn't refresh when token expires, causing potential auth failures

remove early return to always fetch fresh token

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant