> **Building with AI coding agents?** Install the authstack plugin with one command. This equips your agent with accurate Scalekit implementation patterns.
>
> **Recommended** (the `-y` flags skip prompts, so the command runs without an interactive terminal):
> ```bash
> npx -y @scalekit-inc/cli setup -y
> ```
>
> Global:
> ```bash
> npm install -g @scalekit-inc/cli
> scalekit setup -y
> ```
>
> Supports Claude Code, Cursor, GitHub Copilot, Codex + skills for other Agent Skills-compatible agents in `~/.agents/skills`.
> Skills: integrate-agentkit, implement-saaskit, add-mcp-oauth, implement-sso, implement-scim.
> [Full setup guide](https://docs.scalekit.com/dev-kit/build-with-ai/)

---

# 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](/directory/guides/supabase/).

## 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](/sso/guides/test-sso/).

## Connect Scalekit to Supabase

1. ## Create the provider in Supabase

   **Supabase dashboard**

   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 the `custom:` prefix, so your app uses `custom:scalekit`. |
   | **Display Name** | `Scalekit` |
   | **Configuration 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, 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:

   ```bash title="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"]}'
   ```

   **Admin API**

   This script creates the provider and keeps the organization claims in one call, on hosted and self-hosted projects:

   ```javascript title="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 user
     custom_claims_allowlist: ['oid', 'roles', 'groups'],
   })
   if (error) throw error
   console.log('Created', data.identifier)
   ```

   Put `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY` (your secret key `sb_secret_…` or legacy service role key) and the three Scalekit values in a `.env` file. Then run `npm install @supabase/supabase-js dotenv` and `node create-provider.mjs` on 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 `/callback` on a self-hosted project.

   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. ## 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](/guides/dashboard/redirects/#allowed-callback-urls). Until you do, Scalekit stops each sign-in with "The requested redirect_uri is missing in the client configuration."

3. ## 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.

   > caution: email_verified comes from the IdP, or from the organization's verified domains
   >
   > Scalekit passes on the `email_verified` claim the IdP sends, `true` or `false`. When the IdP doesn't send one, which is common with SAML, Scalekit sends `email_verified: true` only if the user's email is on one of the organization's [verified domains](/authenticate/manage-users-orgs/organization-domains/). Without it, Supabase refuses the user's first SSO sign-in with `provider_email_needs_verification` and emails them a confirmation link.

   So add each of the customer's email domains to the organization as an [organization domain](/authenticate/manage-users-orgs/organization-domains/) (`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.

## 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:

```javascript title="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:

| Parameter | Use it when |
| --- | --- |
| `login_hint` | You have the user's work email, and the organization's [domain](/authenticate/manage-users-orgs/organization-domains/) 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](/sso/guides/authorization-url/) 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](/sso/reference/sso-integration-errors/).

### 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:

```typescript title="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](https://supabase.com/docs/guides/auth/server-side/nextjs).

## 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:

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

> caution: Don't authorize on user metadata
>
> A signed-in user can change their own `user_metadata` with `supabase.auth.updateUser()`, including `custom_claims`, and the change appears in their next access token. Use it only for display, never in RLS policies or other access checks.

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. ## Create the hook

   In the Supabase dashboard, open **SQL Editor** and run:

   ```sql title="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. ## Turn on the hook

   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:

   ```toml title="supabase/config.toml"
   [auth.hook.custom_access_token]
   enabled = true
   uri = "pg-functions://postgres/public/custom_access_token_hook"
   ```

3. ## Write policies on the claim

   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](https://supabase.com/docs/guides/database/postgres/row-level-security) policies with `auth.jwt()`. For example, on a `projects` table with an `organization_id` column:

   ```sql title="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.

## Test a sign-in

1. Sign in with a user from your test organization.
2. In the Supabase **SQL Editor**, look at the claims Supabase stored for them:

   ```sql
   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"`.

## Next

- [Provision Supabase users with SCIM](/directory/guides/supabase/): create, update and block Supabase users from your customers' directories.
- [Onboard enterprise customers](/sso/guides/onboard-enterprise-customers/): give each customer's IT admin the admin portal to set up their own SSO.
- [Map user attributes to IdP](/sso/guides/sso-user-attributes/): send more user details from the IdP.


---

## More Scalekit documentation

| Resource | What it contains | When to use it |
|----------|-----------------|----------------|
| [/llms.txt](/llms.txt) | Structured index with routing hints per product area | Start here — find which documentation set covers your topic before loading full content |
| [/llms-full.txt](/llms-full.txt) | Complete documentation for all Scalekit products in one file | Use when you need exhaustive context across multiple products or when the topic spans several areas |
| [sitemap-0.xml](https://docs.scalekit.com/sitemap-0.xml) | Full URL list of every documentation page | Use to discover specific page URLs you can fetch for targeted, page-level answers |
