supabase/supabase-js

[Bug] iOS Safari/Chrome: supabase.auth.getSession() intermittently returns null until app is backgrounded/foregrounded

Opened this issue · 0 comments

Summary

On iOS mobile browsers (Safari/Chrome), supabase.auth.getSession() and supabase.auth.getUser() occasionally return null shortly after opening a protected page or calling our API. If the user backgrounds the browser (go to Home screen) and re-opens the app/tab a few seconds later, the session suddenly becomes available again (without re-auth). This leads to intermittent redirects to /signin for protected routes and occasional 401 for protected API, even though the same session works moments later.

This doesn’t reproduce reliably on desktop browsers. It seems related to cookie/session initialization timing in middleware/SSR + client hydration on iOS (WebKit).

Packages / Versions

  • Next.js: 15 (App Router)
  • @supabase/supabase-js: 2.57.4
  • @supabase/ssr: 0.7.0
  • Runtime: Vercel
  • i18n: next-intl middleware composed with Supabase SSR middleware
  • Browsers: iOS Safari / iOS Chrome (WebKit) — intermittent
  • Desktop (Chrome/Edge): mostly fine

Expected behavior

  • Visiting protected pages or calling protected APIs should consistently see a valid session when the user is logged in.
  • getUser() / getSession() should not return null right after open, and then become valid only after background/foreground.

Actual behavior

  • Intermittently on iOS:

    • getUser() / getSession() return null in middleware/SSR and on first client render.
    • Protected page is redirected to /signin (middleware branch) or protected API returns 401.
    • If the user backgrounds the app (goes to iOS Home) and re-opens the tab/window, getSession() starts returning the valid session again without re-authentication.

Reproduction steps (intermittent)

  1. iOS Safari/Chrome, log in successfully (email+password or OAuth).
  2. After some time (minutes to hours), open a protected page (/account or /history) or call a protected API.
  3. Observe session === null in middleware → redirect to /signin, or API returns 401.
  4. Immediately background the app (home button/gesture), then return to the same tab.
  5. Reload or navigate: now the session is present again and everything works.

Code (middleware + cookie sync + client hook)

middleware.ts

import i18n from "@/i18n/config"
import { routing } from "@/i18n/routing"
import { updateSession } from "@/utils/supabase/middleware"
import { createServerClient } from "@supabase/ssr"
import createIntlMiddleware from "next-intl/middleware"
import { type NextRequest, NextResponse } from "next/server"

const handleI18nRouting = createIntlMiddleware({
  locales: i18n.locales,
  defaultLocale: i18n.defaultLocale,
  localeDetection: false,
  localePrefix: "as-needed",
})

async function getUser(request: NextRequest) {
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll() {
          // read-only in middleware
        },
      },
    },
  )
  const { data: { user } } = await supabase.auth.getUser()
  return user
}

const OPEN_API_PREFIXES = ["/api/webhooks"] as const
const PROTECTED_PREFIXES = ["/account"] as const
const PUBLIC_PATHS = [
  "/"
] as const

function stripLocale(pathname: string) {
  for (const locale of routing.locales) {
    if (pathname === `/${locale}`) return "/"
    if (pathname.startsWith(`/${locale}/`)) return pathname.replace(`/${locale}`, "")
  }
  return pathname
}

export default async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  const shouldSkipI18n = pathname.startsWith("/api") || pathname.startsWith("/auth/")
  const baseResponse = shouldSkipI18n ? NextResponse.next({ request }) : handleI18nRouting(request)

  const strippedPath = stripLocale(pathname)
  const isOpenAPI = pathname.startsWith("/api") && OPEN_API_PREFIXES.some((p) => pathname.startsWith(p))
  const isProtectedAPI = pathname.startsWith("/api") && !isOpenAPI
  const isAuthPath = pathname.startsWith("/auth/") || strippedPath.startsWith("/auth/")
  const isPublicPath = PUBLIC_PATHS.some((p) => strippedPath === p || strippedPath.startsWith(`${p}/`))
  const isProtectedPath = PROTECTED_PREFIXES.some((p) => strippedPath.startsWith(p))
  const needsAuth = isProtectedAPI || isProtectedPath

  if (isOpenAPI || isAuthPath || isPublicPath) {
    return await updateSession(request, baseResponse)
  }

  const user = needsAuth ? await getUser(request) : null

  if (isProtectedAPI) {
    if (!user) {
      const unauthorizedResponse = NextResponse.json({ error: "Unauthorized", code: "UNAUTHORIZED" }, { status: 401 })
      return await updateSession(request, unauthorizedResponse)
    }
    return await updateSession(request, baseResponse)
  }

  if (isProtectedPath) {
    if (!user) {
      const url = request.nextUrl.clone()
      url.pathname = "/signin/password_signin"
      const redirectResponse = NextResponse.redirect(url)
      return await updateSession(request, redirectResponse)
    }
    return await updateSession(request, baseResponse)
  }

  return await updateSession(request, baseResponse)
}

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|_vercel|favicon.ico|site.webmanifest|robots.txt|sitemap|opengraph-image|icon|apple-icon|stats|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|json|js|css|txt|xml|woff2|woff|ttf|otf)$).*)",
    "/api/:path*",
  ],
}

utils/supabase/middleware.ts (cookie sync)

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

export async function updateSession(request: NextRequest, response: NextResponse) {
  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 }) => request.cookies.set(name, value))
          cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options))
        },
      },
    },
  )

  await supabase.auth.getUser() // triggers refresh & cookie sync if needed
  return response
}

Client hook

"use client"
import type { Database } from "@/types_db"
import { createClient } from "@/utils/supabase/client"
import type { Session, SupabaseClient } from "@supabase/supabase-js"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"

export function useSupabaseSession() {
  const supabaseRef = useRef<SupabaseClient<Database> | null>(null)
  const getSupabase = useCallback(() => {
    if (!supabaseRef.current) supabaseRef.current = createClient()
    return supabaseRef.current as SupabaseClient<Database>
  }, [])

  const [session, setSession] = useState<Session | null>(null)
  const [isLoading, setIsLoading] = useState(true)

  useEffect(() => {
    const supabase = getSupabase()
    let isMounted = true

    supabase.auth.getSession()
      .then(({ data }) => { if (isMounted) setSession(data.session ?? null) })
      .finally(() => { if (isMounted) setIsLoading(false) })

    const { data } = supabase.auth.onAuthStateChange((event, nextSession) => {
      if (event === "SIGNED_OUT") { setSession(null); return }
      if (nextSession) setSession(nextSession)
    })

    return () => { isMounted = false; data.subscription.unsubscribe() }
  }, [getSupabase])

  const refreshSession = useCallback(async () => {
    try {
      const supabase = getSupabase()
      const { data: userData, error: userError } = await supabase.auth.getUser()
      if (userError || !userData?.user) { setSession(null); return null }
      const { data: sessionData } = await supabase.auth.getSession()
      const next = sessionData?.session ?? null
      setSession(next)
      return next
    } catch {
      setSession(null)
      return null
    }
  }, [getSupabase])

  return useMemo(() => ({ session, isLoading, refreshSession }), [session, isLoading, refreshSession])
}

What we tried / observations

  • The middleware calls supabase.auth.getUser() to trigger refresh + cookie sync (per docs).

  • We compose next-intl middleware first, then call updateSession.

  • We set cookies in both request and response inside setAll (see code).

  • Client side uses getSession() on mount + onAuthStateChange.

  • On iOS, we sometimes see:

    • First load after reopening the tab → getUser()/getSession() return null.
    • After background/foreground, session appears without re-login.
  • Desktop seems stable.

Is there any recommended solution??
Thanks!