fix: force token refresh and clear cookies on failure to avoid logout loop#511
Merged
Conversation
… loop
Switch the token-refresh route from `auth.api.getAccessToken` (which only
refreshes inside Better Auth's 5s threshold) to `auth.api.refreshToken`
(unconditional refresh). This eliminates the 5–10s race window where the
caller's near-expiry margin would redirect to the route but Better Auth
would return 200 OK with no Set-Cookie, sending the browser back into a
redirect loop ("page isn't redirecting properly").
On refresh failure, call `auth.api.signOut` and forward its Set-Cookie
headers onto the /signin redirect so `session_token` and `account_data`
are actually cleared — otherwise the stale account_data cookie keeps
`isTokenNearExpiry` returning true and the user can fall back into the
loop on the next navigation.
Also export the handler as both GET and POST: Next.js `redirect()` uses
307 (method-preserving) outside Server Actions, so a redirect triggered
from a Server Component render that follows a Server Action POST would
otherwise hit this route as POST and 405.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- next 16.2.3 → 16.2.6 (clears GHSA advisories for App Router middleware bypass, image-API DoS, SSRF, RSC cache poisoning, beforeInteractive XSS, etc.) - pnpm.overrides: hono ≥4.12.18 (cache middleware Vary leakage, CSS-in-style injection, JWT NumericDate validation) - pnpm.overrides: kysely ≥0.28.17 (JSON-path traversal) - pnpm.overrides: fast-uri ≥3.1.2 (path traversal + host confusion via ajv) - pnpm.overrides: ip-address ≥10.1.1 (XSS in Address6 HTML emitters via @modelcontextprotocol/sdk → express-rate-limit) `pnpm audit` now reports no known vulnerabilities. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes an auth redirect loop when access tokens are near expiry by ensuring the /api/auth/token-refresh route handler performs an unconditional refresh and performs a full cookie cleanup on refresh failures, preventing users from getting stuck bouncing between /catalog and the refresh endpoint.
Changes:
- Switch token refresh logic from
auth.api.getAccessTokentoauth.api.refreshTokento avoid Better Auth’s internal refresh threshold race. - On refresh failure/exception, call
auth.api.signOut({ asResponse: true })and forward itsSet-Cookieheaders onto the/signinredirect to clear auth cookies. - Export both
GETandPOSThandlers and add Vitest coverage for cookie-forwarding, failure cleanup, and method parity.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/app/api/auth/token-refresh/route.ts | Forces refresh, forwards Set-Cookie, adds sign-out cleanup on failure, and exports GET/POST. |
| src/app/api/auth/token-refresh/route.test.ts | Updates mocks and adds tests for refresh + cookie forwarding, cleanup behavior, and POST handler parity. |
Comments suppressed due to low confidence (2)
src/app/api/auth/token-refresh/route.ts:67
NextResponse.redirect()defaults to 307 (method-preserving). Since this handler is now callable via POST (per the PR description), a POST to/api/auth/token-refreshwill be redirected back tosafeRedirectwith 307 and the browser will re-POST to/catalog(likely 405 / unintended). Consider returning 303 See Other (or conditional 303 for POST, 307 for GET) for redirects back to app pages.
This issue also appears on line 88 of the same file.
const redirectResponse = NextResponse.redirect(
new URL(safeRedirect, BASE_URL),
);
src/app/api/auth/token-refresh/route.ts:93
- Same issue on the failure path:
NextResponse.redirect(new URL("/signin", BASE_URL))will be a 307 by default, so a POST refresh attempt could be redirected to/signinas a POST. Use 303 (or conditional based onrequest.method) to ensure the browser navigates to/signinwith GET.
async function signOutAndRedirect(
requestHeaders: Headers,
): Promise<NextResponse> {
const response = NextResponse.redirect(new URL("/signin", BASE_URL));
try {
const signOutResponse = await auth.api.signOut({
kysely@0.29 dropped the root re-exports of `DEFAULT_MIGRATION_LOCK_TABLE` and `DEFAULT_MIGRATION_TABLE` (moved under `kysely/migration`), which @better-auth/kysely-adapter@1.6.2 still imports from the root entry. The prior `kysely >=0.28.17` override therefore floated to 0.29.2 and broke the Turbopack build with 12 module-export errors (this also cascaded into the Playwright E2E job, which builds before running). Pin to `>=0.28.17 <0.29.0`: still covers the JSON-path traversal CVE (advisory GHSA, patched in 0.28.17) without bumping past the adapter's compatibility line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`NextResponse.redirect()` defaults to 307 (method-preserving). Now that this route also accepts POST (added in the previous commit), a refresh that arrived via POST would have its outbound redirect to /catalog or /signin sent as POST too — both of which only handle GET, producing 405. Switch both the success and failure redirects to 303 See Other, which unconditionally instructs the browser to follow with GET. Caught by Copilot review on the PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kantord
approved these changes
May 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
When a user's access token expires, the Cloud UI gets stuck in a redirect loop between
/catalogand/api/auth/token-refresh— eventually hitting Firefox's "page isn't redirecting properly" error — instead of being cleanly logged out and bounced to/signin. Reported on staging; logs show[API Client] token near expiry, redirecting to token-refresh | path=/catalogrepeating.Root cause
The route handler called
auth.api.getAccessToken, which only triggers a real refresh when the token has <5s left (Better Auth's internal threshold). The caller's near-expiry margin (isTokenNearExpiry) fires at <10s. In the 5–10s window:/catalog→isTokenNearExpiry = true→ redirect to/api/auth/token-refreshgetAccessToken→ 8s > 5s → returns 200 OK with noSet-Cookie(no refresh happened)/catalogwith unchanged cookie → loop until the browser bails outIndependently, when the refresh fails irrecoverably (e.g. refresh token revoked at the provider), the
!ok → /signinbranch leftsession_tokenandaccount_datacookies in place. The user could navigate back to/catalogand re-enter the loop becauseisTokenNearExpirywould still be true.Fix
auth.api.refreshToken(unconditional refresh) — eliminates the 5–10s race window. If we redirected here, we actually refresh.auth.api.signOut({ asResponse: true })and forward itsSet-Cookieheaders onto the/signinredirect, sosession_tokenandaccount_dataare genuinely cleared.GETandPOST: Next.jsredirect()is 307 (method-preserving) outside Server Actions, so a redirect originating from a Server Action POST reaches this route as POST and would otherwise 405.Test plan
pnpm test src/app/api/auth/token-refresh/route.test.ts— 14/14 pass (new tests for failure-cleanup, signOut-failure resilience, and GET/POST parity)pnpm test src/lib/auth src/app/api/auth— 50/50 passpnpm type-check— cleanpnpm lint— clean for changed files/signinwithout loop🤖 Generated with Claude Code