Skip to content
Scalekit Docs
Talk to an Engineer Dashboard

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.

  • 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.

Each change in the directory reaches your handler as one of three events:

EventWhen
organization.directory.user_createdA user is added, or a deactivated user is reactivated.
organization.directory.user_updatedAn active user’s details change.
organization.directory.user_deletedA user is deactivated or removed.

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:

directory_users.sql
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 uuid
language sql
stable
security definer
set 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;

The handler checks each webhook’s signature, then creates, updates or blocks the user:

scim-webhook.mjs
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 one
const 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.

  1. Expose the handler at a public URL, such as with a tunnel, and set that URL on your webhook endpoint in Scalekit.

  2. Add a user to the directory, or assign them to the app in your IdP.

  3. 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_until
    from public.directory_users d
    join auth.users u on u.id = d.supabase_user_id;

    The new user has your Scalekit organization ID and an empty banned_until.

  4. Deactivate the user in the directory. Their banned_until is now about 100 years in the future, and they can’t sign in.