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";
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
}
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],
},
});
}
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);
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?".