DEV Community

Art Levitt
Art Levitt

Posted on

Two Supabase sessions, one browser: cookie partitioning for admin and customer auth

A small Next.js + Supabase pattern that lets the same admin be logged in to both portals at once, without weird middleware hacks.

Most apps that have an admin area and a customer portal hit the same problem eventually: the admin who's debugging an issue wants to log in to a customer account without logging out of admin. With one shared Supabase session cookie, they can't. They sign into the customer portal, the admin session dies, and now they have to re-sign-in to admin afterwards.

Annoying for daily-driver admins. Disastrous when the admin is mid-debug at 2am and loses their place.

The fix is small and clean: give each "area" of the app its own Supabase cookie name. Two cookies, two parallel sessions, same browser.

The setup
In Next.js (with @supabase/ssr), the Supabase server client takes a cookieOptions.name option. By default it's a single global cookie name. Override it per area.

export type AuthArea = "admin" | "portal";
export const AREA_COOKIE_NAME: Record<AuthArea, string> = {
  admin: "sb-admin-auth-token",
  portal: "sb-portal-auth-token",
};
export const AREA_HEADER = "x-app-area";
Enter fullscreen mode Exit fullscreen mode

In your middleware (Next 16: proxy.ts), resolve the area from the URL and write it to a request header so server code downstream knows which cookie to read:

export async function proxy(request: NextRequest) {
  const area = request.nextUrl.pathname.startsWith("/admin")
    ? "admin"
    : "portal";
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set(AREA_HEADER, area);
  // ... refresh the right session, return response
}

Enter fullscreen mode Exit fullscreen mode

And the Supabase server client reads the area from headers and picks the right cookie:

export async function getSupabaseServerClient(area?: AuthArea) {
  const cookieStore = await cookies();
  const hdrs = await headers();
  const resolvedArea: AuthArea =
    area ?? (hdrs.get(AREA_HEADER) === "admin" ? "admin" : "portal");
  return createServerClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
    cookies: { /* getAll / setAll */ },
    cookieOptions: {
      name: AREA_COOKIE_NAME[resolvedArea],
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

That's the whole pattern. Two cookies, two sessions, one browser. The admin can be logged in as themselves on /admin/* and as a test customer on /portal/* simultaneously.

Why pass area as a parameter, not just rely on the header
The auth callback is the one place this matters. When a user signs in to the customer portal but the magic link is being processed in a route handler that runs outside the /portal/* URL prefix, the header-based detection picks the wrong area. Passing an explicit area argument lets the auth callback say "I know I'm finishing an admin sign-in - set the admin cookie".

const supabase = await getSupabaseServerClient("admin");
await supabase.auth.exchangeCodeForSession(code);
Enter fullscreen mode Exit fullscreen mode

This is the kind of edge case that bites you on day 3 of the migration if you don't plan for it.

Caveats

  • RLS policies don't change. Both cookies authenticate the same auth.users row. If your admin happens to also have a customer record, RLS sees them as that customer in the portal area. That's almost always what you want.
  • Cookie size. You now ship two auth cookies on every request. They're small (~few hundred bytes each) but if you have other large cookies, watch your headers.
  • Sign-out has to be area-scoped too. Don't write a global "sign out" that nukes both cookies โ€” let each area sign itself out independently.

When you don't need this
If your admin and customer flows don't overlap - same person never plays both roles โ€” a single session is simpler. Don't add this complexity speculatively. Add it the first time an admin asks you "can I be logged in as both?".

Top comments (0)