Skip to content

useSupabaseUser() still returns null in middlewares on refresh — v2.0.7 fix is too narrow #611

@XStarlink

Description

@XStarlink

Versions

  • @nuxtjs/supabase: 2.0.7
  • nuxt: 4.4.2

TL;DR

PR #594 (v2.0.7) only restores currentUser when useSsrCookies: false. But with ssr: false (global SPA) AND useSsrCookies: true (the default), neither the server plugin nor the client plugin pre-populates currentUser before middlewares run. The race condition from #565 is still there for this very common combo.

My config

// nuxt.config.ts
export default defineNuxtConfig({
  ssr: false,
  modules: ['@nuxtjs/supabase'],
  supabase: {
    redirect: false,
    // useSsrCookies: true (default — I need it for serverSupabaseUser() in API routes)
  },
})

I can't set useSsrCookies: false because I rely on serverSupabaseUser(event) in dozens of Nitro server routes for auth. They need the session stored in an httpOnly cookie that the server can read.

Repro

  1. Login successfully
  2. Navigate to a protected page (works ✅)
  3. Refresh the page → intermittently redirected to /login then back to the page once the session finally hydrates ❌

The behaviour is a race — sometimes the cookie read finishes before the middleware, sometimes not.

Root cause

In src/runtime/plugins/supabase.client.ts (v2.0.7):

// In SPA mode, restore session from storage before auth middleware runs.
// This prevents a race condition where middleware checks session before it's hydrated.
// See: https://github.com/nuxt-modules/supabase/issues/496
if (!useSsrCookies) {
  const { data } = await client.auth.getSession()
  if (data.session) {
    currentSession.value = data.session
    const { data: claimsData } = await client.auth.getClaims()
    currentUser.value = claimsData?.claims ?? null
  }
}

The guard if (!useSsrCookies) assumes that when useSsrCookies: true, the server plugin (supabase.server.ts) has already populated currentSession / currentUser via serverSupabaseSession / serverSupabaseUser during SSR, and that the state is then hydrated to the client.

This assumption breaks when ssr: false globally. With ssr: false:

  • supabase.server.ts is never executed (no server render at all).
  • No SSR hydration happens.
  • supabase.client.ts returns from setup() without touching currentSession / currentUser.
  • createBrowserClient reads the session cookie asynchronously via the INITIAL_SESSION event, which fires after middlewares have already run.

Execution timeline in this case:

1. supabase.client.ts setup() runs (enforce: 'pre')
   - createBrowserClient instantiated
   - if (!useSsrCookies) { ... }   ← skipped
   - onAuthStateChange registered
   - setup() returns
2. Route middlewares run
   - useSupabaseUser() === null    ← redirect to /login 🚨
3. INITIAL_SESSION event fires
   - currentSession populated
   - getClaims() resolves → currentUser populated
4. Watcher in /login page sees user → redirect back to original page

This is exactly the race described in #565. PR #594 fixed it for useSsrCookies: false, but not for SPA mode with SSR cookies enabled.

Why useSsrCookies: true users are stuck

We can't disable useSsrCookies because we depend on server-side auth (serverSupabaseUser, serverSupabaseClient) in Nitro API routes — these require the session to be stored in cookies that the server can parse. So this combo (ssr: false + useSsrCookies: true) is very common: a SPA frontend that calls a Nitro/server backend with auth.

Current workaround (what we have to do today)

// app/middleware/auth.ts
export default defineNuxtRouteMiddleware(async () => {
  const supabase = useSupabaseClient()
  // Bypass useSupabaseUser() because it's null on refresh in SPA mode
  const { data } = await supabase.auth.getClaims()
  if (!data?.claims) return navigateTo('/login')
})

It works but defeats the purpose of having a reactive useSupabaseUser() composable, and forces every middleware to make its own claims call.

Suggested fix

Drop the if (!useSsrCookies) guard. There is no reason to skip the pre-population in client setup when SSR cookies are enabled:

  • If the server plugin already populated the state, calling getSession() again on the client is cheap (it reads the cached session, no network call when JWT signing keys are configured).
  • If the server plugin did not run (SPA mode), this is the only code path that populates state before middlewares.
// Always pre-populate on the client, regardless of useSsrCookies.
// Cheap when state is already hydrated, necessary when it isn't (ssr: false).
const { data } = await client.auth.getSession()
if (data.session) {
  currentSession.value = data.session
  const { data: claimsData } = await client.auth.getClaims()
  currentUser.value = claimsData?.claims ?? null
}

Alternative, if you want to keep the conditional: also run when state is empty:

if (!useSsrCookies || !currentSession.value) {
  // ...
}

Happy to open a PR if you confirm the approach.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions