The answer. Social login has three legs and the middle one is yours. The client calls signInWithOAuth({ provider, options: { redirectTo } }), the user consents at the provider, and Auth redirects to redirectTo with a ?code=; your callback route exchanges it for a session with the cookie-carrying client. Only now do you know who arrived, and the user already exists in auth.users, created before your code could object. So the callback is where policy lives: check the email domain or an invite against the tenant, and on mismatch sign out and show an error; do not delete the user, who may be a legitimate member of another tenant with data. Then complete first-time setup idempotently: if the tenant claim is missing, add it (merging into the existing array, not replacing it), insert the profile with the provider's full_name, insert the membership. A second login skips all of it.
The pattern.
// GET /auth/callback?code=… (route handler with the SSR cookie client)
const { data, error } = await supabase.auth.exchangeCodeForSession(code);
if (error) return redirect("/error?type=oauth");
const user = data.user, domain = user.email?.split("@")[1];
const { data: t } = await admin.from("tenants").select("id").eq("id", tenant).eq("domain", domain).maybeSingle();
if (!t) { await supabase.auth.signOut(); return redirect("/error?type=register_mail_mismatch"); }
if (!(user.app_metadata.tenants as string[] | undefined)?.includes(tenant)) { // first login here
await admin.auth.admin.updateUserById(user.id, { app_metadata: { tenants: [tenant, ...(user.app_metadata.tenants ?? [])] } });
const { data: p } = await admin.from("profiles").insert({ user_id: user.id, full_name: user.user_metadata.full_name }).select().single();
await admin.from("memberships").insert({ tenant, profile_id: p!.id, role: "member" });
}
return redirect("/app");
Watch out.
redirectTomust be in the Redirect URLs allow-list, wildcards included, or the provider flow ends on the Site URL.updateUserByIdreplacesapp_metadatakeys wholesale; spread the existing array or the user loses other tenants.- The claim in this session is from before the update. Force a refresh (
refreshSession()) or the first page load still sees the old token.
Related: disable-public-signups-mint-users-server-side · custom-claims-through-the-access-token-hook-rbac · anonymous-sign-ins-mint-real-jwts-gate-them-in-rls