Skip to content

Show only the OAuth buttons your Supabase project has switched on

Read the public /auth/v1/settings endpoint on the server, cache it, and fail closed, so a sign-in page never shows a Google button that ends on an error page.

Supabase Auth3 min readships at docs/solutions/supabase-auth/oauth-buttons-from-auth-settings.md

Tags: supabase · auth · oauth · nextjs · sign-in

A sign-in page with a "Continue with GitHub" button for a provider that is off in the Supabase dashboard looks fine until someone clicks it. They leave your site, land on a Supabase error page that says the provider is not enabled, and most of them do not come back.

The usual fix is an env flag per provider (NEXT_PUBLIC_ENABLE_GITHUB=true). That is a second switch that drifts from the real one: someone turns GitHub on in the dashboard and forgets the flag, or turns it off and leaves the button.

Supabase already publishes the real switch.

The endpoint

GET https://<project-ref>.supabase.co/auth/v1/settings
apikey: <publishable (anon) key>

It needs only the publishable key, which is public by design, and answers with the project's sign-in configuration:

{
  "external": {
    "email": true,
    "google": true,
    "github": false,
    "azure": true,
    "apple": false
  },
  "disable_signup": false,
  "mailer_autoconfirm": false
}

external is keyed by Supabase's provider ids. Microsoft is azure.

Parse it, and only what you read

Keep the parse pure, apart from the fetch, so it is unit tested without a network:

import { z } from "zod";

export const OAUTH_PROVIDERS = [
  { id: "google", label: "Google" },
  { id: "github", label: "GitHub" },
  { id: "azure", label: "Microsoft" },
] as const;

const settingsResponse = z.object({
  external: z.record(z.string(), z.unknown()),
  disable_signup: z.boolean().optional(),
});

export function parseAuthSettings(json: unknown) {
  const parsed = settingsResponse.safeParse(json);
  if (!parsed.success) return FALLBACK;
  const { external, disable_signup } = parsed.data;
  return {
    providers: OAUTH_PROVIDERS.filter((p) => external[p.id] === true),
    emailEnabled: external.email !== false,
    signupDisabled: disable_signup === true,
    known: true,
  };
}

Three details that matter:

  • === true, not truthy. A string "true" from a proxy or a future format change should not light up a button.
  • The display order comes from your list, not from the response. Google first is the order people expect.
  • Unknown keys are ignored. A GoTrue upgrade that adds a field must not break sign-in.

Fail closed, but not all the way

When the call fails (no network, a wrong URL, an outage), show no OAuth buttons and keep the email form:

export const FALLBACK = {
  providers: [],
  emailEnabled: true,
  signupDisabled: false,
  known: false,
};

No buttons, because a button for a provider that might be off is the failure you set out to avoid. The email form stays, because hiding it would lock everyone out during a blip. If email really is off, the form's own error says so.

Fetch on the server, cached

export async function getAuthSettings() {
  try {
    const response = await fetch(`${SUPABASE_URL}/auth/v1/settings`, {
      headers: { apikey: SUPABASE_ANON_KEY },
      next: { revalidate: 300 },
      signal: AbortSignal.timeout(3000),
    });
    if (!response.ok) return FALLBACK;
    return parseAuthSettings(await response.json());
  } catch {
    return FALLBACK;
  }
}
  • next: { revalidate: 300 } puts the answer in Next's data cache for five minutes: one call per window, not one per visitor. Switching a provider on shows its button within five minutes, with no deploy.
  • The timeout keeps a slow Supabase from hanging your sign-in page.
  • Server-side, so the page arrives with the right buttons. Fetching in the browser means a flash of no buttons, then buttons.

The page passes providers to a client component that renders one button each and calls signInWithOAuth. The same list drives the sign-up page and a "Connected accounts" settings card, so all three always agree.

Use the same list server-side

A server action that links an identity should check the list too, not just the form value:

const { provider } = z.object({ provider: z.enum(["google", "github", "azure"]) }).parse(input);
if (!(await getAuthSettings()).providers.some((p) => p.id === provider)) {
  return { error: "That sign-in method is turned off." };
}

A form can be posted with any value. The check costs nothing: it is the cached answer.

Test it

it("shows only providers whose flag is literally true", () => {
  const settings = parseAuthSettings({ external: { google: true, github: "true", azure: 1 } });
  expect(settings.providers.map((p) => p.id)).toEqual(["google"]);
});

it("fails closed on anything that is not the settings shape", () => {
  for (const junk of [null, "oops", { external: null }]) {
    expect(parseAuthSettings(junk).providers).toEqual([]);
  }
});

Then by hand: switch a provider off in the dashboard, wait five minutes, and check the button is gone. Point the URL at a host that does not exist and check the page still renders with the email form.