Upstream bug draft — @supabase/auth-js (now in supabase/supabase-js mono-repo)
Repo: https://github.com/supabase/supabase-js (package: packages/core/auth-js)
Published version observed: @supabase/auth-js@2.104.1
Source path: packages/core/auth-js/src/GoTrueClient.ts
Title
_recoverAndRefresh console.errors an AuthApiError that's already returned through the contract — noise in SSR/Edge environments
Summary
In SSR contexts (Next.js middleware on Vercel Edge Runtime), every supabase.auth.getUser() call against a session with a stale refresh token produces a [Hd [AuthApiError]: Invalid Refresh Token: Refresh Token Not Found] line in server logs. The function itself behaves correctly — it returns { user: null, error: AuthSessionMissingError } and the auth flow continues — but the noisy log line is gratuitous because the underlying error has already been handled and surfaced through the public API contract.
The log originates from GoTrueClient.js:3838 inside _recoverAndRefresh. The error is also passed through _returnResult by _callRefreshToken's catch (which is correct), so logging it again in the caller duplicates surfacing.
Source location
packages/core/auth-js/src/GoTrueClient.ts, inside _recoverAndRefresh (current master, verified 2026-06-01):
// packages/core/auth-js/src/GoTrueClient.ts (around line 4690)
const { error } = await this._callRefreshToken(currentSession.refresh_token)
if (error) {
// AuthRefreshDiscardedError means a concurrent signOut already
// cleared storage and fired SIGNED_OUT. Don't run _removeSession
// again here, or we'll emit a duplicate SIGNED_OUT.
if (isAuthRefreshDiscardedError(error)) {
this._debug(debugName, 'refresh discarded by commit guard', error)
} else {
console.error(error) // ← line 4699 — the noise source
if (!isAuthRetryableFetchError(error)) {
this._debug(
debugName,
'refresh failed with a non-retryable error, removing the session',
error
)
await this._removeSession()
}
}
}
_callRefreshToken has already returned { data: null, error } via the standard contract (its catch block correctly maps isAuthError(error) → returned result); the caller has the error available and can decide what to do with it. The unconditional console.error on line 4699 bypasses the normal error-handling surface and emits a server-log entry for an error that is part of the documented return contract.
Note: the discarded-error branch immediately above this line uses this._debug(...) instead of console.error — the suggested fix is to do the same for the general path.
Repro
Minimal Next.js 14+ project with @supabase/ssr:
// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookies) => {
cookies.forEach(({ name, value }) => request.cookies.set(name, value))
response = NextResponse.next({ request })
cookies.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
)
},
},
}
)
await supabase.auth.getUser()
return response
}
- Sign in normally
- Manually invalidate the refresh token (Supabase Dashboard → Authentication → Sessions → delete the active session), but keep the
sb-* cookies in the browser
- Navigate to any page covered by the middleware
- Server logs show
[Hd [AuthApiError]: Invalid Refresh Token: Refresh Token Not Found] despite no thrown exception and no failed control flow
Expected behavior
_recoverAndRefresh should not console.error an error that's already part of the documented return contract. Two reasonable fixes:
- Drop the
console.error. Callers receive the error through the existing return path; the duplicate log adds no diagnostic value.
- Gate it behind
logDebugMessages. The function already calls this._debug(debugName, 'refresh failed ...') two lines below — keep just that, drop the bare console.error.
Impact
- Vercel / Cloudflare / Deno deployments accumulate ~4-8 ERROR-level log lines per stale-session window per user. At scale this drowns out real auth errors.
- Looks like an uncaught exception in Edge Runtime serialization (
[Hd [AuthApiError]: format), causing repeated false-positive investigations.
- No functional impact — middleware redirect-to-login still works correctly.
Workarounds considered (none viable)
- try/catch around
getUser() — catches nothing; the library doesn't throw on this path.
- Monkey-patch
console.error — unsafe in Edge Runtime where isolates serve concurrent requests sharing globals.
- Detect stale cookies before calling
getUser() — cookies are encrypted/signed; parsing couples consumers to internal format.
There is no consumer-side fix. The log line can only be removed by patching the library.
Suggested patch
if (isAuthRefreshDiscardedError(error)) {
this._debug(debugName, 'refresh discarded by commit guard', error)
} else {
- console.error(error)
+ this._debug(debugName, 'refresh failed', error)
if (!isAuthRetryableFetchError(error)) {
this._debug(
debugName,
'refresh failed with a non-retryable error, removing the session',
error
)
await this._removeSession()
}
}
This matches the pattern of the discarded-error branch immediately above (_debug instead of console.error) and keeps the side-effect (_removeSession() clearing cookies via the @supabase/ssr storage adapter) intact. Consumers who want to observe the error explicitly can still subscribe via onAuthStateChange('SIGNED_OUT', ...) or wrap getUser() themselves.
If you'd prefer not to silently downgrade an ERROR to DEBUG, a second acceptable option is gating the console.error behind this.logDebugMessages, so opt-in debug consumers still get it but production SSR deployments don't accumulate the noise.
Happy to open a PR with whichever of the two approaches you prefer.
Upstream bug draft —
@supabase/auth-js(now insupabase/supabase-jsmono-repo)Repo: https://github.com/supabase/supabase-js (package:
packages/core/auth-js)Published version observed:
@supabase/auth-js@2.104.1Source path:
packages/core/auth-js/src/GoTrueClient.tsTitle
_recoverAndRefreshconsole.errors an AuthApiError that's already returned through the contract — noise in SSR/Edge environmentsSummary
In SSR contexts (Next.js middleware on Vercel Edge Runtime), every
supabase.auth.getUser()call against a session with a stale refresh token produces a[Hd [AuthApiError]: Invalid Refresh Token: Refresh Token Not Found]line in server logs. The function itself behaves correctly — it returns{ user: null, error: AuthSessionMissingError }and the auth flow continues — but the noisy log line is gratuitous because the underlying error has already been handled and surfaced through the public API contract.The log originates from
GoTrueClient.js:3838inside_recoverAndRefresh. The error is also passed through_returnResultby_callRefreshToken's catch (which is correct), so logging it again in the caller duplicates surfacing.Source location
packages/core/auth-js/src/GoTrueClient.ts, inside_recoverAndRefresh(currentmaster, verified 2026-06-01):_callRefreshTokenhas already returned{ data: null, error }via the standard contract (its catch block correctly mapsisAuthError(error)→ returned result); the caller has the error available and can decide what to do with it. The unconditionalconsole.erroron line 4699 bypasses the normal error-handling surface and emits a server-log entry for an error that is part of the documented return contract.Note: the discarded-error branch immediately above this line uses
this._debug(...)instead ofconsole.error— the suggested fix is to do the same for the general path.Repro
Minimal Next.js 14+ project with
@supabase/ssr:sb-*cookies in the browser[Hd [AuthApiError]: Invalid Refresh Token: Refresh Token Not Found]despite no thrown exception and no failed control flowExpected behavior
_recoverAndRefreshshould notconsole.erroran error that's already part of the documented return contract. Two reasonable fixes:console.error. Callers receive the error through the existing return path; the duplicate log adds no diagnostic value.logDebugMessages. The function already callsthis._debug(debugName, 'refresh failed ...')two lines below — keep just that, drop the bareconsole.error.Impact
[Hd [AuthApiError]:format), causing repeated false-positive investigations.Workarounds considered (none viable)
getUser()— catches nothing; the library doesn't throw on this path.console.error— unsafe in Edge Runtime where isolates serve concurrent requests sharing globals.getUser()— cookies are encrypted/signed; parsing couples consumers to internal format.There is no consumer-side fix. The log line can only be removed by patching the library.
Suggested patch
if (isAuthRefreshDiscardedError(error)) { this._debug(debugName, 'refresh discarded by commit guard', error) } else { - console.error(error) + this._debug(debugName, 'refresh failed', error) if (!isAuthRetryableFetchError(error)) { this._debug( debugName, 'refresh failed with a non-retryable error, removing the session', error ) await this._removeSession() } }This matches the pattern of the discarded-error branch immediately above (
_debuginstead ofconsole.error) and keeps the side-effect (_removeSession()clearing cookies via the@supabase/ssrstorage adapter) intact. Consumers who want to observe the error explicitly can still subscribe viaonAuthStateChange('SIGNED_OUT', ...)or wrapgetUser()themselves.If you'd prefer not to silently downgrade an
ERRORtoDEBUG, a second acceptable option is gating theconsole.errorbehindthis.logDebugMessages, so opt-in debug consumers still get it but production SSR deployments don't accumulate the noise.Happy to open a PR with whichever of the two approaches you prefer.