DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Three Supabase clients, one cookie jar, and the response object our proxy is not allowed to replace

PubTrivia signs people in with Supabase, and the auth code is 248 lines spread across three files. Two of them are tiny. The third is where every interesting decision lives, and most of those decisions are about a cookie that four different pieces of code want to write at once.

The shape comes from @supabase/ssr, which hands you not one client but three, because a Next.js app has three places that read cookies and only two of them can write them. We use two of the three.

The one that nothing imports

The browser client:

import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
    return createBrowserClient(
        process.env.NEXT_PUBLIC_SUPABASE_URL!,
        process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
    )
}
Enter fullscreen mode Exit fullscreen mode

Seven lines, no cookie handling at all, because in a browser there is a document.cookie and the library knows where to find it. It is also the only file in the repository with zero call sites. Grep the app, the components, the hooks and the lib directory for supabase/client and nothing comes back.

That is not an oversight, it is the shape of the app. Signing in, signing up, signing out, the Google flow and both halves of the password reset are all Server Actions:

"use server"
import { createClient } from '@/utils/supabase/server'

export async function loginUser(currentState, formData) {
    const supabase = await createClient()
    const { error } = await supabase.auth.signInWithPassword({
        email: formData.get('email') as string,
        password: formData.get('password') as string,
    })
    if (error) return { message: error.message }
    revalidatePath('/', 'layout')
    redirect('/dashboard')
}
Enter fullscreen mode Exit fullscreen mode

The login form is a client component, but the only client-side thing it does is useActionState and a toast. No auth library in the bundle, no token in JavaScript, no sign-in that can half-succeed because a fetch was cancelled by a navigation. The seven line file stays because the day we add anything that needs auth in the browser, this is what it will need, and because deleting it would make the trio look like a pair. If you want to watch a server action do the whole job, the signup form posts to one: no network request from JavaScript, one POST, and the response is a redirect carrying a cookie.

The one that ignores its own failures

The server client, used by Server Components and Server Actions, is 28 lines, and the interesting four are a catch block with nothing in it:

setAll(cookiesToSet) {
    try {
        cookiesToSet.forEach(({ name, value, options }) =>
            cookieStore.set(name, value, options)
        )
    } catch {
        // Called from a Server Component, which cannot set cookies.
        // Safe to ignore when the proxy refreshes sessions.
    }
}
Enter fullscreen mode Exit fullscreen mode

A Server Component renders during a response that may already have begun streaming, so Next throws if you try to set a cookie from one. The library does not know whether it is being called from a component or an action, so it calls setAll either way and lets you decide what to do when it fails.

This catch is narrower than it looks. Every one of our auth writes happens in a Server Action, and an action can set cookies, so on the sign in path the forEach succeeds and the session cookie is written exactly as you would hope. The throw is for the other caller: a Server Component that read the session while rendering a page, found the token nearly expired, and got a new one it has no legal way to store.

Swallowing it is correct there, and it is only correct because of a precondition that lives in a different file. If this client is the only thing refreshing the session, then an empty catch means the refresh silently never reaches the browser and the user is signed out when the current token expires. The reason it is safe for us is that something upstream has already refreshed the token before any component rendered.

That something is the third client.

The one with the rule

In Next.js 16 the middleware file is proxy.ts, and ours calls updateSession, which creates the third client:

let supabaseResponse = NextResponse.next({ request })

const supabase = createServerClient(url, key, {
    cookies: {
        getAll() {
            return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
            cookiesToSet.forEach(({ name, value }) =>
                request.cookies.set(name, value)
            )
            supabaseResponse = NextResponse.next({ request })
            cookiesToSet.forEach(({ name, value, options }) =>
                supabaseResponse.cookies.set(name, value, options)
            )
        },
    },
})
Enter fullscreen mode Exit fullscreen mode

Read that setAll again, because it does three things and two of them look redundant.

It writes the new cookies onto the request, so that Server Components rendering further down this same request read the refreshed token rather than the expired one they arrived with. It then rebuilds the response from that mutated request. Then it writes the cookies onto the response, so the browser replaces what it is holding.

The rebuild in the middle is why there is a let. The variable is reassigned from inside a callback the library owns, which means the object you have at the end of the function is not the object you had at the beginning. Supabase's own documentation states the consequence plainly: return the supabaseResponse object that setAll last built, because an earlier response does not carry the refreshed cookies and the user is signed out on the next request.

So the last line of our 213 line file is a return with a comment on it:

// Return supabaseResponse as-is to keep auth cookies in sync.
// Creating a new NextResponse here would drop the refreshed tokens.
return supabaseResponse
Enter fullscreen mode Exit fullscreen mode

This is the failure mode I like least in all of web development, because it is invisible in development. Your token has not expired yet, so setAll never fires, so the object is never reassigned, so returning a fresh NextResponse.next() works perfectly for an hour and then signs people out. On a quiz night the person affected is the host, mid round, holding a phone with forty people waiting on them.

The docs give the escape hatch for when you genuinely need a different response: copy the cookies across, and copy cache-control, expires and pragma too.

Our two legitimate replacements

We return a different response in three places, and all three are redirects, which is the case where losing the refreshed token does not matter because the browser is about to come back and get a new one.

if (!user && !isPublicRoute(pathname)) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    url.searchParams.set('next', pathname)
    return NextResponse.redirect(url)
}

if (user && pathname === '/') {
    const url = request.nextUrl.clone()
    url.pathname = '/dashboard'
    return NextResponse.redirect(url)
}
Enter fullscreen mode Exit fullscreen mode

The first one is observable from outside. Ask for a page behind the session and you are sent to the sign in form with your original destination preserved in the query string, so signing in lands you where you were going instead of on a dashboard you then have to navigate out of. The second is why a signed in host never sees the marketing homepage again.

The third replacement is the interesting one, because it deliberately destroys cookies rather than preserving them:

const logoutResponse = NextResponse.redirect(url)
request.cookies.getAll().forEach(({ name }) => {
    if (name.startsWith('sb-')) {
        logoutResponse.cookies.delete(name)
    }
})
return logoutResponse
Enter fullscreen mode Exit fullscreen mode

That is the concurrent session cap refusing a new login. Keeping the refreshed token here would be actively wrong: the whole point is that this browser should stop presenting credentials, so the response that says no is also the response that clears the sb- prefixed cookies.

The method the docs tell you never to trust

Between createServerClient and any of our own logic there is exactly one call:

const { data: { user } } = await supabase.auth.getUser()
Enter fullscreen mode Exit fullscreen mode

Nothing is allowed to go above it. It is what refreshes the session and triggers setAll, so a redirect decided before it runs is a redirect decided on a token that may already be stale.

Further down, on dashboard routes only, we also call the method Supabase's documentation says never to trust in server code:

const { data: { session } } = await supabase.auth.getSession()
const jti = session?.access_token ? parseJwtJti(session.access_token) : null
Enter fullscreen mode Exit fullscreen mode

The warning is real and worth repeating: getSession() reads the session out of the cookie without revalidating it, so anything that treats its output as proof of identity is trusting a value the user can forge. We are not using it for identity. Identity came from getUser() several lines earlier, over the network. This call exists only to get at the token's jti claim, which gets hashed and used as an opaque key for counting how many devices an account has signed in. A forged token here buys an attacker the ability to miscount their own sessions.

The JWT is parsed by hand, without verifying the signature, which is the same bargain stated twice:

function parseJwtJti(token: string): string | null {
    try {
        const payload = token.split('.')[1]
        if (!payload) return null
        const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')))
        return decoded.jti ?? null
    } catch {
        return null
    }
}
Enter fullscreen mode Exit fullscreen mode

atob and a replace for base64url, in a try/catch that returns null on anything unexpected. No verification, because nothing downstream of it is a security decision.

What this costs per request

getUser() is a network call to the auth server, and it happens on every request the matcher does not exclude. That is the price of a session refresh that is correct, and it is the reason the matcher excludes static assets, images, robots.txt, sitemap.xml and /api by name.

It is also the reason the session cap check is fenced behind pathname.startsWith('/dashboard'). Players joining a quiz go through /play, forty phones at once on venue WiFi, and none of them have accounts. Adding two Redis round trips to that path to enforce a cap on an account that does not exist would be latency spent on nothing.

Worth noting honestly: the current Supabase guidance for this file is getClaims(), which verifies the token's signature locally against a cached copy of the project's public keys on projects using asymmetric signing keys, instead of calling the auth server on every request. We are still on getUser(). The correctness is the same; the round trip is not.

Why a quiz product cares this much

Because the person signing in is doing it in a pub, five minutes before the first round, on whatever connection the venue has. Everything players do, scanning the code on the table and typing a team name, happens with no account at all, which is deliberate: the fewer people who need a session, the fewer people a cookie bug can lock out. The host is the only person in the room with credentials, and the cost of getting this file wrong is that one person, at the worst possible moment.

Three clients, 248 lines, two of them in use. The one in the browser has no callers, the one in a Server Component has to fail quietly, and the one in the proxy needs you to return the exact object a callback handed back to you.

Top comments (0)