[PR #9059] [MERGED] fix(next-js): replace cookie probe with header-based RSC detection in nextCookies #25308

Closed
opened 2026-04-15 22:49:26 -05:00 by GiteaMirror · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/better-auth/better-auth/pull/9059
Author: @gustavovalverde
Created: 4/9/2026
Status: Merged
Merged: 4/9/2026
Merged by: @ping-maxwell

Base: mainHead: fix/nextcookies-router-refresh-loop


📝 Commits (6)

  • b1bb6cd fix(next-js): replace cookie probe with header-based RSC detection in nextCookies
  • 731990f chore: trim changeset to user-facing summary
  • bc07a67 test: remove vacuous cookiesMock assertion from unavailable-headers test
  • e9c6f80 chore: use verified GitHub permalink in source comment
  • 4c62c31 chore: restore Next.js docs reference in after hook catch block
  • 31ca078 Merge branch 'main' into fix/nextcookies-router-refresh-loop

📊 Changes

5 files changed (+205 additions, -27 deletions)

View changed files

.changeset/fix-nextcookies-router-refresh.md (+5 -0)
packages/better-auth/src/integrations/next-js.test.ts (+163 -0)
📝 packages/better-auth/src/integrations/next-js.ts (+33 -23)
📝 packages/better-auth/src/plugins/two-factor/otp/index.ts (+3 -3)
📝 packages/better-auth/src/plugins/two-factor/totp/index.ts (+1 -1)

📄 Description

Problem

PR #7763 changed the nextCookies() before hook from header-based RSC detection to a cookie probe: it called cookies().set() then cookies().delete() on every /get-session request to test whether it was running in a Server Component.

In Next.js, cookies().set() unconditionally triggers router cache invalidation -- even if the value hasn't changed and even if maxAge is 0. Every .set() call marks pathWasRevalidated = ActionDidRevalidateStaticAndDynamic, invalidating the entire prefetch cache on the client (source: request-cookies.ts L112-L157).

This caused:

  • Infinite router refresh loops from Server Actions (#8464)
  • Leaked __better-auth-cookie-store probe cookie (#8828)
  • Combined with two-factor delete-then-set ordering, a brief null-session window during TOTP/OTP enrollment (#6077)

Solution

Detect RSC context by reading RSC and next-action headers from next/headers instead of probing cookies().set(). Zero cookie mutations, zero side effects.

Three layers:

  • _flag === "router": HTTP requests through the router handle cookies via response headers. No skip needed.
  • RSC: 1 without next-action: RSC flight requests where cookies cannot be written. Skip session refresh.
  • Everything else (Server Actions, Route Handlers, direct API calls): Allow refresh normally.

Reorder two-factor session operations

verifyTOTP and verifyOTP enrollment paths now set the new session cookie before deleting the old session, matching the order already used by enableTwoFactor and disableTwoFactor.

Known trade-off

RSC: 1 is present during client-side RSC flight requests but absent during initial page loads (first visit, hard refresh). During initial SSR, Server Components still cannot write cookies, yet our check does not skip refresh. The after hook's cookies().set() fails silently (existing try/catch), and the DB session gets extended while the cookie stays stale.

This is a temporary, self-correcting mismatch: the cookie cache expires within maxAge (default 5 min), and the next request resolves via DB lookup. This same gap existed when PR #7625's header-based detection was live before #7763 replaced it, with no issues reported.

The alternative (!next-action alone) covers initial SSR but incorrectly skips refresh for Route Handler auth.api.* calls—a worse trade-off.

Testing

5 new regression tests in next-js.test.ts

Closes

Closes #8464
Closes #8828
Closes #6077

Supersedes:
Closes #6107
Closes #8462.


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/better-auth/better-auth/pull/9059 **Author:** [@gustavovalverde](https://github.com/gustavovalverde) **Created:** 4/9/2026 **Status:** ✅ Merged **Merged:** 4/9/2026 **Merged by:** [@ping-maxwell](https://github.com/ping-maxwell) **Base:** `main` ← **Head:** `fix/nextcookies-router-refresh-loop` --- ### 📝 Commits (6) - [`b1bb6cd`](https://github.com/better-auth/better-auth/commit/b1bb6cdf12830741b711ee7b1db228cd165d53b9) fix(next-js): replace cookie probe with header-based RSC detection in nextCookies - [`731990f`](https://github.com/better-auth/better-auth/commit/731990f8a76e7ea7a264c0c1c1367983baa704b3) chore: trim changeset to user-facing summary - [`bc07a67`](https://github.com/better-auth/better-auth/commit/bc07a67fb355e40e7cbcb99d934179f1c541eefe) test: remove vacuous cookiesMock assertion from unavailable-headers test - [`e9c6f80`](https://github.com/better-auth/better-auth/commit/e9c6f805d93e2bc1654b83335887dfe42d41a982) chore: use verified GitHub permalink in source comment - [`4c62c31`](https://github.com/better-auth/better-auth/commit/4c62c3124bbe11dffedae552024a672c9fbca865) chore: restore Next.js docs reference in after hook catch block - [`31ca078`](https://github.com/better-auth/better-auth/commit/31ca0787a29ad2e8a3a1a59a93e8a56bb5c26d8a) Merge branch 'main' into fix/nextcookies-router-refresh-loop ### 📊 Changes **5 files changed** (+205 additions, -27 deletions) <details> <summary>View changed files</summary> ➕ `.changeset/fix-nextcookies-router-refresh.md` (+5 -0) ➕ `packages/better-auth/src/integrations/next-js.test.ts` (+163 -0) 📝 `packages/better-auth/src/integrations/next-js.ts` (+33 -23) 📝 `packages/better-auth/src/plugins/two-factor/otp/index.ts` (+3 -3) 📝 `packages/better-auth/src/plugins/two-factor/totp/index.ts` (+1 -1) </details> ### 📄 Description ## Problem PR #7763 changed the `nextCookies()` before hook from header-based RSC detection to a cookie probe: it called `cookies().set()` then `cookies().delete()` on every `/get-session` request to test whether it was running in a Server Component. In Next.js, **`cookies().set()` unconditionally triggers router cache invalidation** -- even if the value hasn't changed and even if `maxAge` is `0`. Every `.set()` call marks `pathWasRevalidated = ActionDidRevalidateStaticAndDynamic`, invalidating the entire prefetch cache on the client ([source: `request-cookies.ts` L112-L157](https://github.com/vercel/next.js/blob/8c5af211d580/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts#L112-L157)). This caused: - **Infinite router refresh loops** from Server Actions (#8464) - **Leaked `__better-auth-cookie-store` probe cookie** (#8828) - Combined with two-factor **delete-then-set ordering**, a brief null-session window during TOTP/OTP enrollment (#6077) ## Solution ### Replace cookie probe with header inspection Detect RSC context by reading `RSC` and `next-action` headers from `next/headers` instead of probing `cookies().set()`. Zero cookie mutations, zero side effects. Three layers: - **`_flag === "router"`**: HTTP requests through the router handle cookies via response headers. No skip needed. - **`RSC: 1` without `next-action`**: RSC flight requests where cookies cannot be written. Skip session refresh. - **Everything else** (Server Actions, Route Handlers, direct API calls): Allow refresh normally. ### Reorder two-factor session operations `verifyTOTP` and `verifyOTP` enrollment paths now set the new session cookie *before* deleting the old session, matching the order already used by `enableTwoFactor` and `disableTwoFactor`. ## Known trade-off `RSC: 1` is present during client-side RSC flight requests but absent during **initial page loads** (first visit, hard refresh). During initial SSR, Server Components still cannot write cookies, yet our check does not skip refresh. The after hook's `cookies().set()` fails silently (existing try/catch), and the DB session gets extended while the cookie stays stale. This is a **temporary, self-correcting mismatch**: the cookie cache expires within `maxAge` (default 5 min), and the next request resolves via DB lookup. This same gap existed when PR #7625's header-based detection was live before #7763 replaced it, with no issues reported. The alternative (`!next-action` alone) covers initial SSR but incorrectly skips refresh for Route Handler `auth.api.*` calls—a worse trade-off. ## Testing 5 new regression tests in `next-js.test.ts` ## Closes Closes #8464 Closes #8828 Closes #6077 Supersedes: Closes #6107 Closes #8462. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
GiteaMirror added the pull-request label 2026-04-15 22:49:26 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#25308