Skip to content

fix(auth): resolve onboarding redirect loop without disabling cookieCache#7

Open
jacksonkasi1 wants to merge 1 commit intoorganization-v2from
feature/fix-onboarding-cache-loop
Open

fix(auth): resolve onboarding redirect loop without disabling cookieCache#7
jacksonkasi1 wants to merge 1 commit intoorganization-v2from
feature/fix-onboarding-cache-loop

Conversation

@jacksonkasi1
Copy link
Copy Markdown
Owner

@jacksonkasi1 jacksonkasi1 commented May 1, 2026

Problem

After completing onboarding steps, RequireOnboarding guards on both `web` and `tanstack` apps would redirect users back to `/onboarding` in a loop. The workaround in place was disabling `session.cookieCache` entirely — meaning every `getSession` call hit the database, defeating the cache's purpose.

Root Cause

Better Auth's cookieCache stores a signed copy of the session payload (including shouldOnboard, activeOrganizationId) in the session cookie. The onboarding step handlers wrote to the database directly (db.update(sessionTable), adapter.updateOnboardingState), but never re-issued the signed cookie. So the cookie cache returned stale pre-onboarding values on subsequent requests, and the guard looped indefinitely until the cache expired.

Fix

Re-enable cookieCache with maxAge: 60 seconds, and refresh the signed cookie after every DB mutation that changes session-relevant state:

  1. hooks.after middleware — now intercepts /onboarding/step/* and /onboarding/skip-step/* paths. After the onboarding plugin has run adapter.updateOnboardingState (which sets shouldOnboard: false on the completion step), refreshSessionCookie() re-reads the fresh session via internalAdapter.findSession and re-issues the signed cookie. The very next guard check reads the correct values — no loop.

  2. createOrganization handler — also calls refreshSessionCookie() inline after writing activeOrganizationId to the session row, so the cookie reflects the org even mid-flow (before the completion step fires).

  3. New utilpackages/auth/src/utils/refresh-session-cookie.ts wraps internalAdapter.findSession + setSessionCookie with error isolation so a cookie refresh failure never breaks the underlying mutation.

Files Changed

  • packages/auth/src/auth.ts — re-enable cookieCache, import refreshSessionCookie, expand hooks.after, call in createOrganization handler
  • packages/auth/src/utils/refresh-session-cookie.ts — new helper

Test Plan

  • New user signs up → automatically enters onboarding (createOrganization step)
  • User creates an org → redirected to inviteMembers step — no loop back to createOrganization
  • User completes or skips inviteMembers → redirected to dashboard — no redirect loop
  • User refreshes dashboard — stays on dashboard (cookie cache serves fresh values)
  • User invited via link → skips onboarding → lands on dashboard without loop
  • bun run check-types passes on all workspaces

Trade-offs / Notes

  • maxAge: 60s means in the absolute worst case (network failure mid-refresh) a user could see a stale redirect for up to 60 seconds. In practice the cookie is always refreshed before the response is sent.
  • The frontend getSession({ fetchOptions: { cache: "no-store" } }) guards remain unchanged — they bypass HTTP cache, which is orthogonal to the signed-cookie cache.

Summary by Sourcery

Re-enable session cookie caching and ensure session cookies are refreshed after onboarding and organization creation to prevent onboarding redirect loops.

Enhancements:

  • Enable short-lived session cookie cache with a 60-second TTL to balance performance and staleness risk.
  • Add a reusable utility to reload the current session from the database and re-issue the signed session cookie, with error-tolerant logging.
  • Hook onboarding step and skip-step routes, as well as organization creation, to refresh the session cookie immediately after session-related mutations.

…ache

Re-enable session.cookieCache (maxAge: 60s) and fix the root cause: when
onboarding step handlers wrote directly to DB (sessionTable.activeOrganizationId,
userTable.shouldOnboard), the signed cookie cache was never refreshed, so
RequireOnboarding guards read stale values and looped back to /onboarding.

Fix:
- hooks.after now intercepts /onboarding/step/* and /onboarding/skip-step/*
  paths. After the plugin has run adapter.updateOnboardingState (which sets
  shouldOnboard=false on the completion step), refreshSessionCookie() re-reads
  the fresh session from DB via internalAdapter.findSession and re-issues the
  signed cookie — so the very next guard check sees the updated values.
- The createOrganization step handler also calls refreshSessionCookie() inline
  after writing activeOrganizationId to the session row, giving the client an
  updated cookie mid-flow (before the completion step).
- Added packages/auth/src/utils/refresh-session-cookie.ts helper that wraps
  internalAdapter.findSession + setSessionCookie with error isolation.
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented May 1, 2026

Reviewer's Guide

Re-enables Better Auth's session cookie cache while ensuring it is refreshed after onboarding-related session mutations to prevent stale session data from causing redirect loops, primarily by adding a reusable refresh helper and wiring it into onboarding middleware and organization creation flows.

Sequence diagram for onboarding step completion with session cookie cache refresh

sequenceDiagram
    actor User
    participant WebApp
    participant AuthServer
    participant OnboardingPlugin
    participant SessionDB
    participant Cookie

    User->>WebApp: Complete onboarding step
    WebApp->>AuthServer: POST /onboarding/step/*
    activate AuthServer

    AuthServer->>OnboardingPlugin: Handle step
    OnboardingPlugin->>SessionDB: Update shouldOnboard and currentOnboardingStep
    SessionDB-->>OnboardingPlugin: Updated session row
    OnboardingPlugin-->>AuthServer: Step handled

    Note over AuthServer: hooks.after middleware runs
    AuthServer->>AuthServer: Path check /onboarding/step/* or /onboarding/skip-step/*
    AuthServer->>AuthServer: refreshSessionCookie(ctx)

    AuthServer->>SessionDB: internalAdapter.findSession(sessionToken)
    SessionDB-->>AuthServer: Fresh session and user
    AuthServer->>Cookie: setSessionCookie(ctx, session, user)
    Cookie-->>AuthServer: Signed cookie with updated cache
    deactivate AuthServer

    User->>WebApp: Navigate to guarded route
    WebApp->>AuthServer: GET /get-session
    AuthServer->>Cookie: Read signed cookie cache
    Cookie-->>AuthServer: Fresh shouldOnboard and activeOrganizationId
    AuthServer-->>WebApp: Session with updated onboarding state
    WebApp-->>User: Allow access without redirect loop
Loading

Sequence diagram for createOrganization flow with immediate cookie cache refresh

sequenceDiagram
    actor User
    participant WebApp
    participant AuthServer
    participant SessionDB
    participant Cookie

    User->>WebApp: Submit createOrganization
    WebApp->>AuthServer: POST /onboarding/step/createOrganization
    activate AuthServer

    AuthServer->>SessionDB: Insert organization row
    SessionDB-->>AuthServer: Organization created

    AuthServer->>SessionDB: Update sessionTable.activeOrganizationId
    SessionDB-->>AuthServer: Session row updated

    AuthServer->>AuthServer: refreshSessionCookie(ctx)
    AuthServer->>SessionDB: internalAdapter.findSession(sessionToken)
    SessionDB-->>AuthServer: Fresh session and user
    AuthServer->>Cookie: setSessionCookie(ctx, session, user)
    Cookie-->>AuthServer: Signed cookie with updated activeOrganizationId

    AuthServer-->>WebApp: Response with organizationId and slug
    deactivate AuthServer

    User->>WebApp: Continue onboarding
    WebApp->>AuthServer: Guarded request uses /get-session
    AuthServer->>Cookie: Read signed cookie cache
    Cookie-->>AuthServer: Session reflecting activeOrganizationId
    AuthServer-->>WebApp: Session
    WebApp-->>User: Correct step routing without loop
Loading

Class diagram for auth configuration and refreshSessionCookie utility

classDiagram
    class AuthConfig {
      +configureAuth(env)
    }

    class SessionConfig {
      +bool cookieCacheEnabled
      +number cookieCacheMaxAgeSeconds
    }

    class HooksAfterMiddleware {
      +handle(ctx)
    }

    class RefreshSessionCookieUtil {
      +refreshSessionCookie(ctx)
    }

    class InternalAdapter {
      +findSession(sessionToken)
    }

    class CookieHelper {
      +setSessionCookie(ctx, session, user)
    }

    class Logger {
      +info(message)
      +error(message)
    }

    AuthConfig --> SessionConfig : configures
    AuthConfig --> HooksAfterMiddleware : registers
    HooksAfterMiddleware --> RefreshSessionCookieUtil : calls
    HooksAfterMiddleware --> InternalAdapter : uses via ctx.context.internalAdapter
    RefreshSessionCookieUtil --> InternalAdapter : reads fresh session
    RefreshSessionCookieUtil --> CookieHelper : reissues signed cookie
    RefreshSessionCookieUtil --> Logger : logs errors
    HooksAfterMiddleware --> Logger : logs session updates

    SessionConfig : cookieCacheEnabled = true
    SessionConfig : cookieCacheMaxAgeSeconds = 60

    HooksAfterMiddleware : +onboardingPathsPrefixStep = /onboarding/step/
    HooksAfterMiddleware : +onboardingPathsPrefixSkip = /onboarding/skip-step/
    HooksAfterMiddleware : +getSessionPath = /get-session

    RefreshSessionCookieUtil : +safeForAnyAuthenticatedEndpoint
    RefreshSessionCookieUtil : +noOpWhenNoSessionToken
    RefreshSessionCookieUtil : +toleratesCookieRefreshFailures
Loading

File-Level Changes

Change Details Files
Re-enable Better Auth session cookie cache with a short TTL.
  • Turn session.cookieCache back on with enabled: true and maxAge: 60 seconds so cached session payloads expire quickly.
  • Remove the previous workaround that fully disabled cookieCache intended to avoid onboarding/dashboard redirect loops.
packages/auth/src/auth.ts
Refresh the signed session cookie after onboarding step mutations to keep guards in sync with DB state.
  • Extend hooks.after middleware to detect /onboarding/step/* and /onboarding/skip-step/* paths after the onboarding plugin updates onboarding fields in the database.
  • Call a new refreshSessionCookie helper from the middleware so the signed session cookie (and its cache) is re-issued immediately after onboarding completion or skip, preventing redirect loops.
  • Ensure the rest of the after hook logic continues to run only for the /get-session endpoint.
packages/auth/src/auth.ts
Refresh the session cookie immediately after updating activeOrganizationId during organization creation.
  • After createOrganization updates the session row to set activeOrganizationId, invoke refreshSessionCookie to re-issue the signed cookie within the same request.
  • Avoid relying solely on the hooks.after pass (which runs after adapter.updateOnboardingState) so mid-onboarding flows see the new active organization right away.
packages/auth/src/auth.ts
Add a reusable, fault-tolerant utility to re-read the session from the DB and re-issue the signed session cookie.
  • Introduce refreshSessionCookie(ctx) which reads the current session token from ctx.context.session, fetches the fresh session+user via ctx.context.internalAdapter.findSession, and calls setSessionCookie to update the signed cookie and cookie cache.
  • Handle missing or concurrently-deleted sessions as no-ops to avoid breaking callers.
  • Wrap the refresh logic in a try/catch and log errors via @repo/logs.logger so failures to refresh the cookie never break the underlying mutation, at worst leaving the cookie cache stale until cookieCache.maxAge elapses.
packages/auth/src/utils/refresh-session-cookie.ts
packages/auth/src/auth.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 1, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0bd51aa7-b11e-4e3c-abea-f242332ef886

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/fix-onboarding-cache-loop

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • In refreshSessionCookie, consider including more contextual metadata in the error log (e.g. ctx.path and possibly a redacted/session identifier) so that intermittent cookie refresh failures are easier to trace in production.
  • The GenericEndpointContext cast around ctx.context.session is fairly loose; if possible, narrow the context type for this helper (e.g. a custom interface with session and internalAdapter) to avoid repeated casting and catch misuse at compile time.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `refreshSessionCookie`, consider including more contextual metadata in the error log (e.g. `ctx.path` and possibly a redacted/session identifier) so that intermittent cookie refresh failures are easier to trace in production.
- The `GenericEndpointContext` cast around `ctx.context.session` is fairly loose; if possible, narrow the context type for this helper (e.g. a custom interface with `session` and `internalAdapter`) to avoid repeated casting and catch misuse at compile time.

## Individual Comments

### Comment 1
<location path="packages/auth/src/utils/refresh-session-cookie.ts" line_range="66-74" />
<code_context>
+      session: fresh.session,
+      user: fresh.user,
+    });
+  } catch (error) {
+    // Never let cookie refresh failures break the mutation - log and continue.
+    // Worst case: cookie cache is stale for `cookieCache.maxAge` seconds.
+    logger.error(
+      `Failed to refresh session cookie cache: ${
+        error instanceof Error ? error.message : String(error)
+      }`,
+    );
+  }
+}
</code_context>
<issue_to_address>
**suggestion:** Log the full error object instead of only the message to preserve stack traces and structured data.

Interpolating only `error.message`/`String(error)` discards the stack and any structured fields. If your logger supports it, prefer passing the error object itself, e.g. `logger.error("Failed to refresh session cookie cache", { error })` or `logger.error(error, "Failed to refresh session cookie cache")`, so you retain full diagnostics when issues occur.

```suggestion
  } catch (error) {
    // Never let cookie refresh failures break the mutation - log and continue.
    // Worst case: cookie cache is stale for `cookieCache.maxAge` seconds.
    logger.error("Failed to refresh session cookie cache", { error });
  }
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +66 to +74
} catch (error) {
// Never let cookie refresh failures break the mutation - log and continue.
// Worst case: cookie cache is stale for `cookieCache.maxAge` seconds.
logger.error(
`Failed to refresh session cookie cache: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: Log the full error object instead of only the message to preserve stack traces and structured data.

Interpolating only error.message/String(error) discards the stack and any structured fields. If your logger supports it, prefer passing the error object itself, e.g. logger.error("Failed to refresh session cookie cache", { error }) or logger.error(error, "Failed to refresh session cookie cache"), so you retain full diagnostics when issues occur.

Suggested change
} catch (error) {
// Never let cookie refresh failures break the mutation - log and continue.
// Worst case: cookie cache is stale for `cookieCache.maxAge` seconds.
logger.error(
`Failed to refresh session cookie cache: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
} catch (error) {
// Never let cookie refresh failures break the mutation - log and continue.
// Worst case: cookie cache is stale for `cookieCache.maxAge` seconds.
logger.error("Failed to refresh session cookie cache", { error });
}

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