Skip to content

Logged out after an hour: Supabase cookie refresh in the App Router

Access tokens expire hourly and Server Components cannot write cookies, so the refresh has to happen in the proxy and be returned on the same response object.

Supabase Auth4 min readships at docs/solutions/supabase-auth/cookie-refresh-in-the-app-router.md

Tags: supabase · nextjs · app-router · cookies · sessions · proxy

Everything works. People sign in, use the app, and about an hour later they are signed out. Sometimes it happens mid-action. There is no error in the logs, no failed request, nothing in the auth dashboard. Reloading fixes it, which makes it sound like a caching bug.

It is not. It is the refresh token having nowhere to go.

Why an hour

A Supabase access token is a JWT that expires after one hour by default. The client is supposed to exchange the refresh token for a new access token before that, and write the new pair back to storage, in a cookie-based setup, back into cookies.

In the App Router, Server Components cannot write cookies. By the time a component renders, the response headers are on their way. Calling cookieStore.set() there throws, or is silently swallowed by the try/catch most implementations wrap it in.

So the refresh happens, the new token exists in memory for the duration of that render, and then it is discarded. The next request arrives with the same old cookie. Once it is truly expired, the user is anonymous.

Where the refresh belongs

The proxy (Next.js 16's renamed middleware) is the one place that can read the incoming cookies and write the outgoing ones. That is why every Supabase App Router setup has an updateSession step:

// src/proxy.ts
import type { NextRequest } from "next/server";
import { updateSession } from "@/lib/auth/proxy";

export async function proxy(request: NextRequest) {
  const headers = new Headers(request.headers);

  const { response, handled } = await updateSession(request, { headers });
  if (handled) return response;

  return NextResponse.next({ request: { headers } });
}

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|[^?]*\\.(?:css|js(?!on)|jpe?g|png|gif|svg|webp|ico|woff2?)).*)",
    "/(api|trpc)(.*)",
  ],
};

The three ways to break it

1. Removing the getUser() call.

Inside updateSession there is a line that looks pointless:

const { data: { user } } = await supabase.auth.getUser();

Nothing downstream may use user. A tidy-up removes it, and the random-logout bug comes back that afternoon. That call is what triggers the refresh: it makes the client evaluate the token, notice it is near expiry, exchange the refresh token and write the new cookies through the setAll handler.

If you must keep a linter quiet, use the value: pass it back to the caller, as updateSession does, so route protection can reuse it without a second round trip.

2. Returning a different response object.

// no
let response = NextResponse.next({ request });
const supabase = createServerClient(url, key, {
  cookies: {
    getAll: () => request.cookies.getAll(),
    setAll: (list) => {
      for (const { name, value, options } of list) response.cookies.set(name, value, options);
    },
  },
});
await supabase.auth.getUser();
return NextResponse.next();   // <- brand new response; the cookies are gone

The refreshed cookies were written onto response. Returning a fresh NextResponse.next() throws them away, and you get exactly the same symptom as having no refresh at all.

The same trap applies to redirects. When the proxy decides to redirect, the redirect is a different response object, so the refreshed cookies have to be copied onto it:

const redirectResponse = NextResponse.redirect(url);
for (const cookie of response.cookies.getAll()) redirectResponse.cookies.set(cookie);
return redirectResponse;

Miss that and the user is redirected to sign-in while holding a perfectly good, newly refreshed session that was discarded on the way out.

3. Ending the chain when nothing happened.

The tempting shape is return response with no condition: it is one line shorter and it is never wrong about cookies. It is wrong about everything after it: an interception chain is a list of steps, and a step that always returns is a step nothing can follow. Every later handler (a rate limiter, a geo redirect, a bot filter) becomes unreachable code that looks installed.

updateSession reports handled so the caller can tell the two cases apart. It is true when the step redirected, and when setAll actually fired, which is the only time the response carries something the browser needs. On an ordinary request with a valid token, neither happened, nothing was written, and falling through costs nothing.

Server Components still swallow the write

createServerSupabase() in src/lib/auth/server.ts wraps its cookie write in a try/catch that does nothing. That is correct only because the proxy runs on every matched request and persists the refresh there. If you ever narrow the matcher so a route renders without passing through the proxy, that route's users will drift out of session with no error.

Route handlers and server actions can write

The exceptions are route handlers and server actions: both can set cookies. It is why the OAuth callback is a route handler: exchangeCodeForSession must persist cookies, and a page cannot.

Checking your work

  • Sign in, then change your machine's clock or wait out the token lifetime, and keep using the app. You stay signed in, and the session cookie's value changes.
  • Watch the set-cookie header on a normal page navigation an hour in: it should carry the refreshed token.
  • Sign in, then hit a route the matcher excludes. If that page thinks you are signed out, the matcher is too narrow.
  • Trigger a proxy redirect (visit a protected page signed out, sign in, come back) and confirm no session is lost along the way.