-
Notifications
You must be signed in to change notification settings - Fork 486
Description
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 returnnull
right after open, and then become valid only after background/foreground.
Actual behavior
-
Intermittently on iOS:
getUser()
/getSession()
returnnull
in middleware/SSR and on first client render.- Protected page is redirected to
/signin
(middleware branch) or protected API returns401
. - 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)
- iOS Safari/Chrome, log in successfully (email+password or OAuth).
- After some time (minutes to hours), open a protected page (
/account
or/history
) or call a protected API. - Observe
session === null
in middleware → redirect to/signin
, or API returns401
. - Immediately background the app (home button/gesture), then return to the same tab.
- 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 callupdateSession
. -
We set cookies in both
request
andresponse
insidesetAll
(see code). -
Client side uses
getSession()
on mount +onAuthStateChange
. -
On iOS, we sometimes see:
- First load after reopening the tab →
getUser()
/getSession()
returnnull
. - After background/foreground, session appears without re-login.
- First load after reopening the tab →
-
Desktop seems stable.
Is there any recommended solution??
Thanks!