Skip to content

Breaking behavior change: exchangeCodeForSession defers SIGNED_IN event in v2.91.0, impacting OAuth cookie writes in SSR/serverless #2037

@dalkommatt

Description

@dalkommatt

Describe the bug

Summary

After upgrading @supabase/supabase-js from 2.90.1 to 2.91.0, OAuth login stopped working (both localhost and Vercel). OTP auth continues to work.

Both OAuth providers tested (Google + Discord) are affected.

The regression appears to be caused by the v2.91.0 change “auth: defer subscriber notification in exchangeCodeForSession to prevent deadlock” (PR #2014).

In Next.js Route Handlers / serverless runtimes using @supabase/ssr, OAuth callbacks commonly rely on the SIGNED_IN event emitted during auth.exchangeCodeForSession() to write auth cookies via the SSR cookie adapter. In v2.91.0, SIGNED_IN is now notified via setTimeout(..., 0), which may not run before the request completes, resulting in no auth cookies being set.

This may be an intentional tradeoff to avoid a deadlock in some environments, but it is a breaking behavior change for serverless/SSR callback handlers that expect exchangeCodeForSession to complete cookie persistence before the response returns.

Affected flow

  • auth.signInWithOAuth() redirects the user to the provider
  • Provider redirects back to /auth/callback?code=...
  • Server route calls auth.exchangeCodeForSession(code)
  • Expected: auth cookies are set and user is signed in
  • Actual (v2.91.0): callback redirects, but cookies are not set; user remains logged out

In apps that protect pages via Next.js middleware (e.g. calling supabase.auth.getClaims() / getUser() on each request), the next navigation to a protected page (like /dashboard) immediately redirects back to /login because no session is present.

Regression

Observed result

  • Callback handler executes and redirects.
  • exchangeCodeForSession does not return an error.
  • No Supabase auth cookies are persisted (e.g. sb-... cookies).
  • Subsequent server/client calls return no session; protected routes redirect to /login.

Expected result

  • exchangeCodeForSession causes the SSR client to persist auth cookies during the callback request.

Suspected cause

v2.91.0 includes:

The compare view shows this change in packages/core/auth-js/src/GoTrueClient.ts inside exchangeCodeForSession:

// previous behavior (v2.90.1)
await this._notifyAllSubscribers('SIGNED_IN', data.session)

// new behavior (v2.91.0)
setTimeout(async () => {
  await this._notifyAllSubscribers('SIGNED_IN', data.session)
}, 0)

In serverless/route handler environments, cookie-writing (via @supabase/ssr’s subscription to auth events) may require the notification to happen within the lifetime of the request.

Environment

  • Next.js: App Router (observed in both localhost dev and Vercel)
  • @supabase/ssr: 0.8.0
  • @supabase/supabase-js: 2.91.0 (regression), 2.90.1 (works)
  • Node: 24.x

Workaround

Pin @supabase/supabase-js to 2.90.1, or insert a macrotask delay after exchangeCodeForSession so the deferred callback runs before returning the response.

This workaround has been verified to restore OAuth sign-in for both Google and Discord in this Next.js app.

await supabase.auth.exchangeCodeForSession(code)
await new Promise((r) => setTimeout(r, 0))

Request

Please confirm whether deferring SIGNED_IN notifications in exchangeCodeForSession is intended for SSR/serverless contexts, and if so, consider documenting it as a breaking behavior for SSR OAuth callbacks.

If not intended, possible fixes could include:

  • Use a microtask (queueMicrotask / Promise.resolve().then(...)) instead of setTimeout, so it runs before the response returns.
  • Only defer notifications in browser contexts.
  • Provide an option to keep notifications synchronous for SSR.
  • Ensure exchangeCodeForSession persists cookie/session state without relying on subscriber timing.

Library affected

supabase-js

Reproduction

No response

Steps to reproduce

Reproduction (Next.js App Router)

  1. Create a Next.js App Router project.
  2. Create a server Supabase client using @supabase/ssr and a cookie adapter (cookies().getAll() / cookies().set() pattern).
  3. Implement a callback route handler that calls exchangeCodeForSession.

Minimal callback route (similar to what many Next.js + Supabase guides recommend):

// app/auth/callback/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/supabase/server'

export async function GET(req: Request) {
  const url = new URL(req.url)
  const code = url.searchParams.get('code')

  if (code) {
    const supabase = await createClient()
    const { error } = await supabase.auth.exchangeCodeForSession(code)
    if (error) return NextResponse.redirect(new URL('/login?error=oauth', url.origin))
  }

  return NextResponse.redirect(new URL('/dashboard', url.origin))
}
  1. Upgrade @supabase/supabase-js from 2.90.1 to 2.91.0.
  2. Perform an OAuth sign-in (e.g. Google).

System Info

System:
    OS: macOS 26.2
    CPU: (10) arm64 Apple M1 Pro
    Memory: 200.19 MB / 32.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 22.21.0 - /Users/matt/.nvm/versions/node/v22.21.0/bin/node
    Yarn: 1.22.22 - /Users/matt/Documents/GitHub/korean-vocabulary-app/node_modules/.bin/yarn
    npm: 10.9.4 - /Users/matt/Documents/GitHub/korean-vocabulary-app/node_modules/.bin/npm
    bun: 1.3.5 - /Users/matt/.bun/bin/bun
    Deno: 2.5.2 - /opt/homebrew/bin/deno
  Browsers:
    Chrome: 143.0.7499.193
    Safari: 26.2
  npmPackages:
    @supabase/mcp-server-supabase: ^0.6.1 => 0.6.1 
    @supabase/ssr: ^0.8.0 => 0.8.0 
    @supabase/supabase-js: 2.91.0 => 2.91.0

Used Package Manager

bun

Logs

No response

Validations

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsupabase-jsRelated to the supabase-js library.

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions