Next.js + Supabase + Google OAuth: User session not detected in middleware after authentication, causing redirect loop

I’m working on a Next.js project with Supabase authentication, and I’m encountering an issue where the user session is not being detected in the middleware after a successful authentication callback. This causes the middleware to continuously redirect to the signin page, creating a redirect loop.

Here’s the relevant code:

/api/auth/callback/route.ts :

import { NextResponse, NextRequest } from "next/server";
import { createClient } from "@/utils/supabase/server";
import config from "@/config";

export const dynamic = "force-dynamic";

// This route is called after a successful login. It exchanges the code for a session and redirects to the callback URL (see config.js).
export async function GET(req: NextRequest) {
  const requestUrl = new URL(req.url);
  const code = requestUrl.searchParams.get("code");
  const next = requestUrl.searchParams.get("next");
  console.log("next in auth callback:", next);
  if (code) {
    const supabase = createClient();
    const result = await supabase.auth.exchangeCodeForSession(code);
    
    console.log("exchangeCodeForSession result:", JSON.stringify(result, null, 2));

    if (result.error) {
      console.error("Error exchanging code for session:", result.error);
      return NextResponse.redirect(requestUrl.origin + '/auth-error');
    }

    // You can access other properties like result.data here if needed
  }

  // URL to redirect to after sign in process completes
  return NextResponse.redirect(requestUrl.origin);
}
 

middelware.ts:

/* middleware.ts */

import { type NextRequest } from 'next/server';
import { updateSession } from '@/utils/supabase/middleware';

export async function middleware(request: NextRequest) {
  return await updateSession(request);
}

export const config = {
  matcher: [
    /*
     * Match all request paths except:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * - images - .svg, .png, .jpg, .jpeg, .gif, .webp
     * Feel free to modify this pattern to include more paths.
     */
    '/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'
  ]
};


/* utils/supabase/middleware.ts */

import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({
    request,
  });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) =>
            request.cookies.set(name, value)
          );
          supabaseResponse = NextResponse.next({
            request,
          });
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  // IMPORTANT: Avoid writing any logic between createServerClient and
  // supabase.auth.getUser(). A simple mistake could make it very hard to debug
  // issues with users being randomly logged out.

  const {
    data: { user },
  } = await supabase.auth.getUser();
  //console data
  console.log("user in middleware:",user)
  console.log("origin url in middleware:", request.nextUrl.basePath);
  if (
    !user &&
    !request.nextUrl.pathname.startsWith("/signin") &&
    !request.nextUrl.pathname.startsWith("/api")
  ) {
    // no user, potentially respond by redirecting the user to the login page
    const url = request.nextUrl.clone();
    
    url.pathname = "/signin";
    return NextResponse.redirect(url);
  }

  // IMPORTANT: You *must* return the supabaseResponse object as it is. If you're
  // creating a new response object with NextResponse.next() make sure to:
  // 1. Pass the request in it, like so:
  //    const myNewResponse = NextResponse.next({ request })
  // 2. Copy over the cookies, like so:
  //    myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())
  // 3. Change the myNewResponse object to fit your needs, but avoid changing
  //    the cookies!
  // 4. Finally:
  //    return myNewResponse
  // If this is not done, you may be causing the browser and server to go out
  // of sync and terminate the user's session prematurely!

  return supabaseResponse;
}

The issue:

  1. The /api/auth/callback route successfully calls exchangeCodeForSession.
  2. However, in the subsequent middleware, getUser() returns null.
  3. Because the user is null, the middleware redirects to the signin page.
  4. This creates a redirect loop, as the user is never detected as authenticated.

Logs:

GET /api/auth/callback?code=97738d2a-3b9f-4c2e-a1aa-d9c667478e29 307 in 29ms
user in middleware: null
user in middleware: null

What I’ve tried:
Logging the result of exchangeCodeForSession in the callback route, which shows a successful session creation.

Checking the middleware multiple times, but it consistently shows the user as null.

Questions:

Why isn’t the user session being detected in the middleware after a successful callback?

Is there a timing issue where the middleware is executing before the session is fully established?

How can I ensure that the session is properly set and detectable in the middleware after the callback?

What’s the best way to prevent the redirect loop while still protecting routes that require authentication?

Environment:

"@supabase/ssr": "^0.4.0",
"@supabase/supabase-js": "^2.38.3",
 "next": "^14.0.0",

Any insights or suggestions would be greatly appreciated. Thank you!