Skip to content

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

@skeleton1231

Description

@skeleton1231

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!


Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions