Provision Supabase users with SCIM
Create, update and block Supabase Auth users from your customers' directories, using Scalekit's SCIM webhooks and the Supabase admin API.
Your enterprise customers sign in to your app with SSO, and their IT teams now want to manage who has access from their directory, such as Okta or Microsoft Entra ID, instead of inviting and removing people by hand. With SCIM, the directory tells Scalekit about each change, Scalekit sends your app a webhook, and a small handler applies the change to Supabase Auth with the admin API.
By the end:
- Users your customers add to their directory are created in Supabase, and their details stay in sync.
- Users your customers deactivate are blocked in Supabase, so they can’t sign in or refresh their session. Reactivated users are unblocked.
To also let these users sign in through their company’s identity provider, see Add SSO to Supabase Auth.
Before you start
Section titled “Before you start”- A Supabase project and its secret key (
sb_secret_…) or legacy service role key. Run the handler on your server; never ship either key to a browser. - Your Scalekit environment URL, client ID and client secret, from Developers > Settings > API Credentials in the Scalekit dashboard.
- An organization in Scalekit with a directory connected, and a webhook endpoint subscribed to the directory user events. Copy the endpoint’s signing secret (
whsec_…). See Set up SCIM provisioning.
What Scalekit sends
Section titled “What Scalekit sends”Each change in the directory reaches your handler as one of three events:
| Event | When |
|---|---|
organization.directory.user_created | A user is added, or a deactivated user is reactivated. |
organization.directory.user_updated | An active user’s details change. |
organization.directory.user_deleted | A user is deactivated or removed. |
Map directory users to Supabase users
Section titled “Map directory users to Supabase users”The handler needs to know which Supabase user each directory user is. A table keeps that mapping. The first time the directory sends someone, a function looks for an existing Supabase user with the same email in the same Scalekit organization: someone who already signed in through that organization’s SSO connection, or someone the handler created for it before. The handler marks the users it creates in app_metadata, which only the admin API can change.
Anyone else with that email is left alone, such as a user who signed up with a password or one from another organization. Otherwise, someone who registered an employee’s email first could take over their account. In that case createUser fails with email_exists and the handler returns 500, so you can sort it out.
Run this SQL in the Supabase SQL Editor:
create table public.directory_users ( directory_user_id text primary key, supabase_user_id uuid not null references auth.users (id) on delete cascade);alter table public.directory_users enable row level security;revoke all on table public.directory_users from anon, authenticated;grant select, insert, update on table public.directory_users to service_role;
create function public.organization_user_id_by_email( p_email text, p_organization_id text, p_directory_user_id text)returns uuidlanguage sqlstablesecurity definerset search_path = ''as $$ select u.id from auth.users u where lower(u.email) = lower(p_email) and ( -- Signed in through the organization's SSO connection exists ( select 1 from auth.identities i where i.user_id = u.id and i.provider = 'custom:scalekit' and i.identity_data -> 'custom_claims' ->> 'oid' = p_organization_id ) -- Created by the handler for the organization, for this directory user -- or one it replaced. Only the admin API can set app_metadata. or ( u.raw_app_meta_data ->> 'organization_id' = p_organization_id and ( u.raw_app_meta_data ->> 'directory_user_id' = p_directory_user_id or exists (select 1 from public.directory_users d where d.supabase_user_id = u.id) ) ) ) limit 1;$$;revoke execute on function public.organization_user_id_by_email(text, text, text) from public, anon, authenticated;grant execute on function public.organization_user_id_by_email(text, text, text) to service_role;Run the webhook handler
Section titled “Run the webhook handler”The handler checks each webhook’s signature, then creates, updates or blocks the user:
import 'dotenv/config'import express from 'express'import { Scalekit } from '@scalekit-sdk/node'import { createClient } from '@supabase/supabase-js'
const scalekit = new Scalekit( process.env.SCALEKIT_ENVIRONMENT_URL, process.env.SCALEKIT_CLIENT_ID, process.env.SCALEKIT_CLIENT_SECRET,)const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY)
// About 100 years: Supabase has no permanent ban, so use a very long oneconst BLOCKED = '876000h'
async function findSupabaseUserId(directoryUser, organizationId) { const { data, error } = await supabase .from('directory_users') .select('supabase_user_id') .eq('directory_user_id', directoryUser.id) .maybeSingle() if (error) throw error if (data) return data.supabase_user_id
// The user may already exist, for example because they signed in with SSO first const { data: id, error: rpcError } = await supabase.rpc('organization_user_id_by_email', { p_email: directoryUser.email, p_organization_id: organizationId, p_directory_user_id: directoryUser.id, }) if (rpcError) throw rpcError return id}
async function upsertUser(directoryUser, organizationId) { const attributes = { email: directoryUser.email, user_metadata: { given_name: directoryUser.given_name, family_name: directoryUser.family_name, }, // Lets a retry find this user if saving the mapping below fails app_metadata: { organization_id: organizationId, directory_user_id: directoryUser.id }, }
let id = await findSupabaseUserId(directoryUser, organizationId) if (id) { // Also lifts the block on a user the directory reactivates const { error } = await supabase.auth.admin.updateUserById(id, { ...attributes, email_confirm: true, ban_duration: 'none', }) if (error) throw error } else { const { data, error } = await supabase.auth.admin.createUser({ ...attributes, email_confirm: true, }) if (error) throw error id = data.user.id }
const { error } = await supabase .from('directory_users') .upsert({ directory_user_id: directoryUser.id, supabase_user_id: id }) if (error) throw error}
async function blockUser(directoryUser, organizationId) { const id = await findSupabaseUserId(directoryUser, organizationId) if (!id) return const { error } = await supabase.auth.admin.updateUserById(id, { ban_duration: BLOCKED }) if (error) throw error}
async function handleEvent(event) { switch (event.type) { // Also sent when the directory reactivates a user case 'organization.directory.user_created': return upsertUser(event.data, event.organization_id) case 'organization.directory.user_updated': if (event.data.active === false) return blockUser(event.data, event.organization_id) return upsertUser(event.data, event.organization_id) // Also sent when the directory deactivates a user case 'organization.directory.user_deleted': return blockUser(event.data, event.organization_id) }}
const app = express()
app.post('/webhooks/scalekit', express.raw({ type: 'application/json' }), async (req, res) => { const payload = req.body.toString() const headers = { 'webhook-id': req.headers['webhook-id'], 'webhook-timestamp': req.headers['webhook-timestamp'], 'webhook-signature': req.headers['webhook-signature'], }
try { scalekit.verifyWebhookPayload(process.env.SCALEKIT_WEBHOOK_SECRET, headers, payload) } catch { return res.status(401).json({ error: 'Invalid signature' }) }
try { await handleEvent(JSON.parse(payload)) return res.status(200).json({ received: true }) } catch (err) { console.error('Failed to process webhook:', err) return res.status(500).json({ error: 'Processing failed' }) }})
app.listen(3000, () => console.log('Listening on http://localhost:3000'))Install the packages with npm install express @scalekit-sdk/node @supabase/supabase-js dotenv, add SCALEKIT_WEBHOOK_SECRET to your .env file, and run node scim-webhook.mjs.
Scalekit retries a webhook that fails, and the handler is safe to run twice, so a retry doesn’t create a second user. Blocking keeps the user’s data, and access tokens issued before the block stay valid until they expire. To delete users instead, call supabase.auth.admin.deleteUser(id) in blockUser.
Test provisioning
Section titled “Test provisioning”-
Expose the handler at a public URL, such as with a tunnel, and set that URL on your webhook endpoint in Scalekit.
-
Add a user to the directory, or assign them to the app in your IdP.
-
In the Supabase SQL Editor, look at the users the handler created:
select distinct u.email, u.raw_app_meta_data ->> 'organization_id' as organization_id, u.banned_untilfrom public.directory_users djoin auth.users u on u.id = d.supabase_user_id;The new user has your Scalekit organization ID and an empty
banned_until. -
Deactivate the user in the directory. Their
banned_untilis now about 100 years in the future, and they can’t sign in.
- Add SSO to Supabase Auth: let these users sign in through their company’s IdP.
- Directory webhooks reference: the fields in the user events.