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.
Before you start
Section titled “Before you start”- 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.
Connect Scalekit to Supabase
Section titled “Connect Scalekit to Supabase”-
Create the provider in Supabase
Section titled “Create the provider in Supabase”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:
Field Value Provider Identifier scalekit. Supabase adds thecustom:prefix, so your app usescustom:scalekit.Display Name ScalekitConfiguration Method Auto-discovery (Recommended) Issuer URL Your Scalekit environment URL, with no trailing slash Client ID and Client Secret Your Scalekit client ID and client secret Scopes openid, email, profileCopy 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"]}'This script creates the provider and keeps the organization claims in one call, on hosted and self-hosted projects:
create-provider.mjs import 'dotenv/config'import { createClient } from '@supabase/supabase-js'const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY)const { data, error } = await supabase.auth.admin.customProviders.createProvider({provider_type: 'oidc',identifier: 'custom:scalekit',name: 'Scalekit',issuer: process.env.SCALEKIT_ENVIRONMENT_URL,client_id: process.env.SCALEKIT_CLIENT_ID,client_secret: process.env.SCALEKIT_CLIENT_SECRET,scopes: ['openid', 'email', 'profile'],// Keep Scalekit's organization claims on the usercustom_claims_allowlist: ['oid', 'roles', 'groups'],})if (error) throw errorconsole.log('Created', data.identifier)Put
SUPABASE_URL,SUPABASE_SERVICE_ROLE_KEY(your secret keysb_secret_…or legacy service role key) and the three Scalekit values in a.envfile. Then runnpm install @supabase/supabase-js dotenvandnode create-provider.mjson your machine or server, never in a browser.Your callback URL is
https://<project-ref>.supabase.co/auth/v1/callback, or your Auth server’s external URL followed by/callbackon a self-hosted project.Supabase finds Scalekit’s endpoints from the issuer URL, so it must match the
issuerin Scalekit’s discovery document exactly. The three claims it now keeps areoid, the user’s Scalekit organization ID;roles, their roles in that organization; andgroups, their IdP groups when the connection maps them. -
Allow the callback URL in Scalekit
Section titled “Allow the callback URL in Scalekit”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.”
-
Add the organization’s domains
Section titled “Add the organization’s domains”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_verifiedwith the valuetrueto the SAML app in their IdP. For an OIDC connection, Scalekit passes on the IdP’s ownemail_verifiedclaim.Keep Confirm email on. Turning it off also lets password sign-ups skip email confirmation, and Supabase then links accounts on the email alone.
Sign users in
Section titled “Sign users in”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:
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:
| Parameter | Use it when |
|---|---|
login_hint | You have the user’s work email, and the organization’s domain is set up in Scalekit. |
domain | You know the user’s email domain. |
organization_id | Your app already knows which customer the user belongs to, for example from a subdomain. |
connection_id | You 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.
Next.js and other server-rendered apps
Section titled “Next.js and other server-rendered apps”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:
import { NextResponse, type NextRequest } from 'next/server'// The server client from Supabase's Next.js setupimport { 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.
Use the organization in RLS policies
Section titled “Use the organization in RLS policies”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:
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.
-
Create the hook
Section titled “Create the hook”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 jsonblanguage plpgsqlstableas $$declareclaims jsonb := event -> 'claims';scalekit_claims jsonb;organization_count int;beginselect count(distinct i.identity_data -> 'custom_claims' ->> 'oid')into organization_countfrom auth.identities iwhere i.user_id = (event ->> 'user_id')::uuidand 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_claimsfrom auth.identities iwhere i.user_id = (event ->> 'user_id')::uuidand i.provider = 'custom:scalekit'order by i.updated_at desclimit 1;end if;if jsonb_exists(scalekit_claims, 'oid') thenclaims := jsonb_set(claims, '{organization_id}', scalekit_claims -> 'oid');end if;if jsonb_exists(scalekit_claims, 'roles') thenclaims := 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; -
Turn on the hook
Section titled “Turn on the hook”Go to Authentication > Auth Hooks, click Add hook > Customize Access Token (JWT) Claims hook, choose
public.custom_access_token_hookand save. With the Supabase CLI, add it tosupabase/config.tomlinstead:supabase/config.toml [auth.hook.custom_access_token]enabled = trueuri = "pg-functions://postgres/public/custom_access_token_hook" -
Write policies on the claim
Section titled “Write policies on the claim”Access tokens for users who signed in through a Scalekit organization now carry
organization_id, plusorganization_roleswhen Scalekit sends roles. Use them in your row level security policies withauth.jwt(). For example, on aprojectstable with anorganization_idcolumn:projects_policy.sql create policy "Members read their organization's projects"on public.projectsfor selectto authenticatedusing (organization_id = (select auth.jwt() ->> 'organization_id'));Editing
user_metadatadoesn’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.
Test a sign-in
Section titled “Test a sign-in”-
Sign in with a user from your test organization.
-
In the Supabase SQL Editor, look at the claims Supabase stored for them:
select u.email, i.identity_data -> 'custom_claims' as scalekit_claimsfrom auth.identities ijoin auth.users u on u.id = i.user_idwhere i.provider = 'custom:scalekit'order by i.updated_at desclimit 5;The newest row shows the Scalekit organization ID, such as
{"oid": "org_149485029758599426", "groups": ["engineering"]}. -
Decode the user’s Supabase access token from
supabase.auth.getSession(). It contains"organization_id": "org_149485029758599426".
- Provision Supabase users with SCIM: create, update and block Supabase users from your customers’ directories.
- Onboard enterprise customers: give each customer’s IT admin the admin portal to set up their own SSO.
- Map user attributes to IdP: send more user details from the IdP.