Skip to content
Scalekit Docs
Talk to an Engineer Dashboard

Add SSO to Supabase Auth

Keep Supabase Auth and add enterprise SSO with Scalekit as a custom OIDC provider, with the user's organization in the access token for RLS policies.

Your app signs users in with Supabase Auth, and now an enterprise customer wants their employees to sign in through their company’s identity provider (IdP), such as Okta or Microsoft Entra ID. You don’t have to replace Supabase to offer single sign-on (SSO). You add Scalekit to Supabase as a custom OpenID Connect (OIDC) provider.

When a user picks SSO, Supabase sends them to Scalekit, Scalekit sends them to their company’s IdP, and the result comes back to Supabase. Supabase then does its usual job: it creates the user, issues the session and enforces your row level security (RLS) policies.

By the end:

  • A user signs in through their company’s IdP and lands in your app with a normal Supabase session.
  • The user’s Scalekit organization is a claim in their Supabase access token, so RLS policies can scope data to it.

To also create and block Supabase users from your customers’ directories, see Provision Supabase users with SCIM.

  • A Supabase project.
  • Your Scalekit environment URL, client ID and client secret, from Developers > Settings > API Credentials in the Scalekit dashboard.
  • An organization in Scalekit with an SSO connection. To set one up for testing, see Test SSO.
  1. Go to Authentication > Sign In / Providers and, under Custom Providers, click New Provider. If you see Enable Custom Providers, click that first. Fill in the form:

    FieldValue
    Provider Identifierscalekit. Supabase adds the custom: prefix, so your app uses custom:scalekit.
    Display NameScalekit
    Configuration MethodAuto-discovery (Recommended)
    Issuer URLYour Scalekit environment URL, with no trailing slash
    Client ID and Client SecretYour Scalekit client ID and client secret
    Scopesopenid, email, profile

    Copy the Callback URL the form shows, then click Create and enable provider.

    The form can’t keep the claims that carry the user’s organization, so add them with one request from your terminal. It uses your secret key (sb_secret_…) from Project Settings > API Keys, which never belongs in browser code:

    Terminal
    curl -X PUT "https://<project-ref>.supabase.co/auth/v1/admin/custom-providers/custom:scalekit" \
    -H "apikey: <your-secret-key>" \
    -H "Content-Type: application/json" \
    -d '{"custom_claims_allowlist": ["oid", "roles", "groups"]}'

    Supabase finds Scalekit’s endpoints from the issuer URL, so it must match the issuer in Scalekit’s discovery document exactly. The three claims it now keeps are oid, the user’s Scalekit organization ID; roles, their roles in that organization; and groups, their IdP groups when the connection maps them.

  2. In the Scalekit dashboard, add the callback URL under Authentication > Redirect URLs > Allowed callback URLs. See Configure redirect URLs. Until you do, Scalekit stops each sign-in with “The requested redirect_uri is missing in the client configuration.”

  3. Supabase’s Confirm email setting, on by default, lets a user in through a provider only when the provider says their email is verified.

    So add each of the customer’s email domains to the organization as an organization domain (ORGANIZATION_DOMAIN). Domains you add in the Scalekit dashboard or with the API are verified right away; domains your customer adds in the admin portal are verified once their DNS record checks out.

    If the organization has no verified domain, have the IdP send the claim instead. For a SAML connection, the customer’s IT admin adds an attribute named email_verified with the value true to the SAML app in their IdP. For an OIDC connection, Scalekit passes on the IdP’s own email_verified claim.

    Keep Confirm email on. Turning it off also lets password sign-ups skip email confirmation, and Supabase then links accounts on the email alone.

With the provider in place, start a sign-in with custom:scalekit and tell Scalekit which SSO connection to use. Most apps ask for the user’s work email and pass it as login_hint, and Scalekit picks the organization that owns the email’s domain:

sign-in.js
import { createClient } from '@supabase/supabase-js'
const supabase = createClient('https://<project-ref>.supabase.co', '<your-publishable-key>')
export async function signInWithSso(email) {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'custom:scalekit',
options: {
redirectTo: 'https://app.example.com/dashboard',
queryParams: { login_hint: email },
},
})
if (error) console.error('Could not start SSO:', error.message)
}

Supabase only redirects to URLs on its allow list, so add your redirectTo URL under Authentication > URL Configuration > Redirect URLs, or to additional_redirect_urls in supabase/config.toml. Otherwise Supabase sends users to your site URL.

If you already know something else about the user, pass that instead of login_hint. Supabase forwards queryParams to Scalekit, and each of these sends the user straight to their IdP:

ParameterUse it when
login_hintYou have the user’s work email, and the organization’s domain is set up in Scalekit.
domainYou know the user’s email domain.
organization_idYour app already knows which customer the user belongs to, for example from a subdomain.
connection_idYou want one specific SSO connection.

See Authorization URL to initiate SSO for how Scalekit picks the connection.

When the user comes back, Supabase creates their account and redirects to redirectTo with a session. If an account with the same verified email already exists, Supabase links the sign-in to it. If the sign-in fails, Supabase redirects to redirectTo with error and error_description in the URL instead; see SSO integration errors.

With @supabase/ssr, Supabase redirects to redirectTo with a code instead of a session. Start the sign-in with createBrowserClient, point redirectTo at a callback route such as https://app.example.com/auth/callback, and exchange the code there. @supabase/ssr stores the session in cookies:

app/auth/callback/route.ts
import { NextResponse, type NextRequest } from 'next/server'
// The server client from Supabase's Next.js setup
import { createClient } from '@/utils/supabase/server'
export async function GET(request: NextRequest) {
const { searchParams, origin } = request.nextUrl
// Pass sign-in errors on to the page that shows them
if (searchParams.has('error')) {
return NextResponse.redirect(`${origin}/login?${searchParams}`)
}
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(searchParams.get('code') ?? '')
if (error) {
return NextResponse.redirect(`${origin}/login?error=${encodeURIComponent(error.message)}`)
}
return NextResponse.redirect(`${origin}/dashboard`)
}

For the server and browser clients, see Supabase’s server-side auth guide for Next.js.

Users can sign in. Next, make sure each one only sees their own organization’s data.

Supabase stores the organization claims on the user’s Scalekit identity and copies them into user_metadata.custom_claims, which is handy for display:

read-organization.js
const { data: { user } } = await supabase.auth.getUser()
console.log('Organization:', user?.user_metadata?.custom_claims?.oid)

For access control, a custom access token hook puts the organization in the access token instead. It reads the user’s Scalekit identity, which users can’t edit, whenever Supabase issues or refreshes a token. A change in the IdP, such as a new role, shows up after the user’s next SSO sign-in.

  1. In the Supabase dashboard, open SQL Editor and run:

    custom_access_token_hook.sql
    create or replace function public.custom_access_token_hook(event jsonb)
    returns jsonb
    language plpgsql
    stable
    as $$
    declare
    claims jsonb := event -> 'claims';
    scalekit_claims jsonb;
    organization_count int;
    begin
    select count(distinct i.identity_data -> 'custom_claims' ->> 'oid')
    into organization_count
    from auth.identities i
    where i.user_id = (event ->> 'user_id')::uuid
    and i.provider = 'custom:scalekit';
    -- A user linked to more than one organization gets no organization claims:
    -- the hook can't tell which organization this token is for.
    if organization_count = 1 then
    -- Claims from the user's most recent Scalekit sign-in. Users can't edit identities.
    select i.identity_data -> 'custom_claims'
    into scalekit_claims
    from auth.identities i
    where i.user_id = (event ->> 'user_id')::uuid
    and i.provider = 'custom:scalekit'
    order by i.updated_at desc
    limit 1;
    end if;
    if jsonb_exists(scalekit_claims, 'oid') then
    claims := jsonb_set(claims, '{organization_id}', scalekit_claims -> 'oid');
    end if;
    if jsonb_exists(scalekit_claims, 'roles') then
    claims := jsonb_set(claims, '{organization_roles}', scalekit_claims -> 'roles');
    end if;
    return jsonb_set(event, '{claims}', claims);
    end;
    $$;
    grant usage on schema public to supabase_auth_admin;
    grant execute on function public.custom_access_token_hook to supabase_auth_admin;
    revoke execute on function public.custom_access_token_hook from authenticated, anon, public;
  2. Go to Authentication > Auth Hooks, click Add hook > Customize Access Token (JWT) Claims hook, choose public.custom_access_token_hook and save. With the Supabase CLI, add it to supabase/config.toml instead:

    supabase/config.toml
    [auth.hook.custom_access_token]
    enabled = true
    uri = "pg-functions://postgres/public/custom_access_token_hook"
  3. Access tokens for users who signed in through a Scalekit organization now carry organization_id, plus organization_roles when Scalekit sends roles. Use them in your row level security policies with auth.jwt(). For example, on a projects table with an organization_id column:

    projects_policy.sql
    create policy "Members read their organization's projects"
    on public.projects
    for select
    to authenticated
    using (organization_id = (select auth.jwt() ->> 'organization_id'));

    Editing user_metadata doesn’t change this claim. A user without it sees no rows: a password user, or one whose email signed in through two organizations’ SSO connections, because the hook can’t tell which organization their token is for.

  1. Sign in with a user from your test organization.

  2. In the Supabase SQL Editor, look at the claims Supabase stored for them:

    select u.email, i.identity_data -> 'custom_claims' as scalekit_claims
    from auth.identities i
    join auth.users u on u.id = i.user_id
    where i.provider = 'custom:scalekit'
    order by i.updated_at desc
    limit 5;

    The newest row shows the Scalekit organization ID, such as {"oid": "org_149485029758599426", "groups": ["engineering"]}.

  3. Decode the user’s Supabase access token from supabase.auth.getSession(). It contains "organization_id": "org_149485029758599426".