Skip to content

auth-js: _recoverAndRefresh console.errors an AuthApiError that's already returned through the contract — log noise in SSR/Edge #2416

@tommytrevino

Description

@tommytrevino

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
}
  1. Sign in normally
  2. Manually invalidate the refresh token (Supabase Dashboard → Authentication → Sessions → delete the active session), but keep the sb-* cookies in the browser
  3. Navigate to any page covered by the middleware
  4. 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:

  1. Drop the console.error. Callers receive the error through the existing return path; the duplicate log adds no diagnostic value.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    auth-jsRelated to the auth-js library.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions