A reversible SCIM migration proxy for moving an existing self-hosted SCIM integration onto WorkOS Directory Sync with zero downtime and safe rollback.
You run scim-bridge in front of your current SCIM endpoint. It dual-writes every change to both your existing app and WorkOS, lets you backfill and verify at your own pace, then cut over — and roll back losslessly at any point before the final commit. A built-in control panel imports directories, holds their SCIM credentials, flips migration modes, runs backfill, and shows the request log and id mappings.
Migrating for real? Start with
docs/migration-guide.md— the end-to-end path from the directory list you hand WorkOS to cutover, including the exact CSV shapes exchanged at each handoff.docs/runbook.mdhas each step's operational depth, anddocs/architecture.mdexplains why it is built this way.
docker build -t scim-bridge .
docker run -p 8080:8080 -e DEMO_MODE=true -e PANEL_AUTH_DISABLED=true scim-bridgePANEL_AUTH_DISABLED=true is what makes the panel open, and it is required
rather than assumed: the bridge refuses to start with /panel unauthenticated
unless you say you meant it. Fine on your laptop for ten minutes; see
Configuration before this is reachable
by anyone else.
Open http://localhost:8080/panel. DEMO_MODE mounts a simulated IdP and a
simulated native app inside the container and points a directory at them, so you
can drive the whole migration — passthrough → dual-write → backfill →
workos-primary → cut over, and roll it back — against nothing but itself. Start
on Live state: seed the directory, then change modes and watch the three
columns converge.
No volume is mounted above, so the demo starts clean every run. Leave
DEMO_MODE off in production.
cp .env.example .env # set PANEL_AUTH_USER and PANEL_AUTH_PASSWORD, and PUBLIC_URL
docker compose up --buildThe first step is not optional: the bridge refuses to start until the control
panel has credentials, and says so. /panel holds every directory's SCIM tokens
— see Configuration. Compose restarts
the container, so a missing setting looks like a restart loop; docker compose logs has the one-line reason.
The control panel is at http://localhost:8080/panel and the SCIM base URL your
IdP points at is http://localhost:8080/scim/v2. The SQLite database persists in
the scim-bridge-data volume.
Not yet. Nothing has been pushed to
ghcr.io/workos/scim-bridge— today the command below fails withunauthorized, which looks like a permissions problem and is really "this has not been published". Build from this checkout until it has; the two paths produce the same image. (docs/releasing.mdtracks the publish, and removing this note is a step in it.)
docker run -p 8080:8080 -v scim-bridge-data:/data \
-e PUBLIC_URL=https://scim-bridge.acme.com \
-e PANEL_AUTH_USER=admin -e PANEL_AUTH_PASSWORD='a password you generated' \
ghcr.io/workos/scim-bridge:latestThe image is published for linux/amd64 and linux/arm64 under one manifest,
so the same command works on an EC2 host, on Cloudflare Containers, and on an
Apple Silicon laptop. :latest is for trying it out — for anything you
depend on, pin a version or a digest: see
Releases and image tags.
npm start runs the same server, with two differences worth knowing before you
lose an evening to them:
- It does not read
.env. Onlydocker composedoes. Outside a container, pass the variables in the environment (PUBLIC_URL=… npm start) or export them. DATABASE_PATHdefaults to/data/scim-bridge.db, which is the path inside the image. On a host machine there is usually no/data, and the server exits at boot withCannot open database because the directory does not exist. Set it:
DATABASE_PATH=./scim-bridge.db PUBLIC_URL=http://localhost:8080 npm startOnly process-wide settings are configured here. Per-directory settings are imported through the control panel.
| Variable | Required | Default | Purpose |
|---|---|---|---|
PUBLIC_URL |
recommended | http://127.0.0.1:$PORT |
Externally reachable base URL your IdP uses; drives the SCIM base URL shown in the panel. |
PORT |
no | 8080 |
HTTP port the server listens on. |
DATABASE_PATH |
no | /data/scim-bridge.db |
SQLite file path. Mount a volume here to persist. The default is a path inside the image; running outside a container, set it to somewhere that exists. |
PANEL_AUTH_USER / PANEL_AUTH_PASSWORD |
yes | — | HTTP Basic credentials guarding the control panel. The bridge refuses to start without them. |
PANEL_AUTH_DISABLED |
no | false |
Set to true to run /panel unauthenticated on purpose, when something in front of the bridge already authenticates it. |
APP_ENCRYPTION_KEY |
no | — | When set, encrypts each directory's native + WorkOS bearer tokens at rest (AES-256-GCM). Keep it stable; leave unset to store them in plaintext. |
DEMO_MODE |
no | false |
Mount the bundled IdP + native-app simulators under /__demo for a self-contained end-to-end demo. |
The /scim/v2 data-plane is always authenticated by the per-directory proxy
token the panel mints — panel auth does not gate it.
Why the panel's credentials are mandatory. The directory page renders each
directory's native and WorkOS bearer tokens into the page, because that is how
you edit them. An unauthenticated panel therefore hands out the credentials this
bridge writes to your application and to WorkOS with, to anyone who can reach
the port. APP_ENCRYPTION_KEY does not change that — it encrypts at rest, and
the panel decrypts to render. The proxy token is safe either way (it is stored
as a hash), but these two cannot be: the bridge has to present them upstream.
Migrating many directories, or starting from scratch? Follow
docs/migration-guide.md— it starts one step earlier, with WorkOS provisioning your directories (they must be created as imported directories; the dashboard can't do it), and covers bulk import.
- Open
/paneland create a directory to migrate. - Paste your existing app's SCIM base URL + bearer token, and the WorkOS directory endpoint + bearer token (from the WorkOS-provisioned sheet — see the migration guide's Step A).
- Press Rotate to get the proxy token. The directory page shows only the last four characters of it — the token itself is stored as a hash and cannot be read back. Rotate mints a new one and displays it once, with a Copy button; take it then, because reloading the page loses it. If you already have a bearer token your IdP presents today, you can supply it as the proxy token at import instead and skip this.
- Paste that token, and the SCIM base URL the page shows, into your IdP's
SCIM configuration. The directory starts in
passthrough, so repointing the IdP changes no behavior — every request still reaches your native app. - Advance the mode:
passthrough → dual-write → backfill → workos-primary → cut over, verifying convergence in the Live/Mappings tabs. Roll back any time before commit.workos-primaryis the one to dwell on: WorkOS answers your IdP while the proxy still writes your app directly, so authority and webhook-dependence stop being one leap — seedocs/runbook.md.
Rotating invalidates the previous token immediately, so a directory whose IdP is already syncing will
401until you paste the new one. On a live directory, rotate at a moment you can follow straight through.docs/runbook.mdhas the recovery paths.
The proxy translates every request from your IdP into a WorkOS SCIM call under
the migrated-id contract: WorkOS addresses each resource by the id your own
system minted, carried in an X-WorkOS-Migrated-Id: {id} header, and echoes that
id back — so your IdP never sees a WorkOS-internal id. Post-decoupling only
POST creates a resource; PUT/PATCH/DELETE resolve by id and 404 on a
miss. So a first-touch write runs the dance PUT /{kind}/{id} → 404 →
POST /{kind} (both with the header), and a POST 409 (create race) retries the
PUT to resolve the winner.
| Your IdP sends (→ proxy) | Proxy sends to WorkOS | How WorkOS handles it |
|---|---|---|
POST /Users (create) |
PUT /Users/{id} + header → 404 → POST /Users + header |
Creates the user and adopts {id} as its id. In dual-write, {id} is the id your native app minted (learned from its 201); after cutover it is derived from the IdP externalId. |
PUT /Users/{id} (replace) |
PUT /Users/{id} + header (→ 404 → POST /Users + header) |
Full replace; a missing first-touch resource 404s the PUT and self-heals via POST. |
PATCH /Users/{id} (update) |
PATCH /Users/{id} |
Applied verbatim (no header). Any ids inside the body are translated to the WorkOS side first. |
DELETE /Users/{id} |
DELETE /Users/{id} |
Removes the resource; the proxy drops its id mapping. |
POST/PUT/PATCH/DELETE /Groups... |
Same shape as Users | Group members[].value ids are translated between your ids and WorkOS's in both directions. |
GET (any) |
Not sent to WorkOS | Reads are served from whichever side is authoritative for the current mode (your native app in passthrough/dual-write, WorkOS from workos-primary on). |
Id strategy. Every id the proxy sends WorkOS (path ids and group
members[].value) is mapped through its id_mappings table, so the two systems
stay linked. If a directory's WorkOS endpoint does not honor the migrated-id
contract, the proxy falls back to a plain POST, records the WorkOS-minted id
(strategy = fallback-post), and keeps translating through that mapping — the
migration still works, the ids just aren't shared.
DEMO_MODE=true mounts a simulated IdP and native SCIM app in-process (under
/__demo) and points a new directory at them, so you can drive the whole
migration loop with no real IdP or WorkOS account. Leave it off in production.
The /__demo mounts are not behind panel auth. The panel drives them by
fetching its own loopback URL and that request carries no credentials, so gating
them would make DEMO_MODE and PANEL_AUTH_* mutually exclusive. Nothing real
is behind them — a fake IdP and a fake customer app — and they do not exist
unless DEMO_MODE is set.
After a post-cutover delete, the Live state panes will disagree on purpose: the mock WorkOS removes the record (as real WorkOS does on a SCIM DELETE), while the native pane keeps the user as an Inactive tombstone with its memberships. That is the reference listener's deactivate-in-place semantic working — not drift. Whether and when to purge tombstones is a retention-policy decision the listener deliberately does not make.
The proxy handles many directories — each imported directory is routed by its own proxy token. The bundled simulator, though, models a single directory (its mock WorkOS and native app share one store), so the demo runs one directory end-to-end. The reference DSync listener resolves each event's directory and migration mode per-directory (
directoryModeForEventinworkers/native/listener.ts) by polling the proxy's status endpoint; a real customer's listener does the same with the WorkOSdirectory_idthe event carries — see below.
Your app's DSync event listener must ignore events while the proxy is still writing your app directly and handle them once it isn't. The proxy exposes a per-directory status endpoint that makes that call for you:
GET {PUBLIC_URL}/status/directories/{directory_id}
Authorization: Bearer {proxy_token}
→ { directory_id, workos_directory_id, mode, native_authoritative,
apply_dsync_events, updated_at }
It accepts the WorkOS directory id (directory_...) DSync events carry — set
it on the directory in the panel — or the bridge's own id, authenticated by the
same per-directory proxy token as the /scim/v2 data-plane. Responses are
cache-friendly (ETag, Cache-Control: max-age=5).
Key your listener on apply_dsync_events — the instruction. Don't derive it
from mode or from native_authoritative, which reports who owns the data and
only looks equivalent today. See
docs/listener-status.md for the contract and a
client snippet.
npm install
npm run dev # React Router dev server (control panel)
npm run build # production client + server build
npm start # run the full server (proxy + panel) against the build
npm run typecheck # react-router typegen + tsc -b (workers/, server/, tests/)
npm run typecheck:gate # asserts the gate rejects a deliberate type error
npm run typecheck:app # the control panel, which is not gated yetThe test suite runs against the SQLite driver by default. Point
TEST_DATABASE_URL at a Postgres server and it also runs the datastore
conformance and schema-parity cases; add TEST_ENGINE=postgres and the whole
suite runs on Postgres instead:
docker run -d --rm --name sb-pg -e POSTGRES_PASSWORD=test -e POSTGRES_DB=scimtest \
-p 55432:5432 postgres:16
export TEST_DATABASE_URL=postgres://postgres:test@127.0.0.1:55432/scimtest
npm test # every test on SQLite (+ the Postgres-only cases)
npm run test:postgres # every test on Postgres
npm run test:engines # asserts both runs covered the same testsEach worker migrates one Postgres schema and resets it between tests, so a run creates a handful rather than one per test. A worker killed mid-run (a timeout, a Ctrl-C) leaves its schema behind; to clear leftovers:
psql "$TEST_DATABASE_URL" -tAc \
"SELECT format('DROP SCHEMA %I CASCADE;', schema_name) FROM information_schema.schemata \
WHERE schema_name ~ '^t[0-9]+_[0-9]+$'" | psql "$TEST_DATABASE_URL"CI runs all three test commands. test:engines compares the JSON reports the
first two leave in .vitest/, so it costs no extra runs — and it fails if a test is skipped on one
engine but not the other, which is how "make it pass on Postgres" becomes "don't
run it on Postgres".
npm run dev serves the panel with HMR; the /scim proxy data-plane runs under
npm run build && npm start (or in Docker).
scim-bridge is a single Node process:
Storage: the database holds every directory, its migration mode and its id mappings, so
DATABASE_PATHmust be on a volume that survives a restart (or useDATABASE_DRIVER=postgres). Boot warns when it is not. See docs/runbook.md#durable-storage.
server/— a Hono server that routes/scim/v2/*to the proxy, serves the React Router control panel for everything else, applies migrations on boot, and provides the datastore driver the app code talks to (Datastoreinworkers/shared/datastore.ts; SQLite by default).workers/proxy— the SCIM migration proxy (data-plane).workers/shared— SCIM translation, id-mapping, backfill, and DB helpers.app/— the React Router control panel (vendored WorkOS design system).workers/native,workers/idp— the demo simulators (DEMO_MODE only).
The datastore is a configured choice: a SQLite file (default) or Postgres, behind one narrow interface — see docs/runbook.md#durable-storage for which to pick and why.
Every release is a git tag vX.Y.Z, a GitHub
Release whose notes say what
changes for an operator, and a matching set of image tags. What changed in each
version is in CHANGELOG.md.
| Reference | Moves? | Use it for |
|---|---|---|
ghcr.io/workos/scim-bridge@sha256:… |
never | Production. The only reference that cannot change under you; each release's notes print it. |
ghcr.io/workos/scim-bridge:0.3.0 |
never in practice | Production, if you would rather read a version than a hash. A published version tag is never overwritten. |
ghcr.io/workos/scim-bridge:0.3 |
with each patch | Picking up fixes without a redeploy decision. |
ghcr.io/workos/scim-bridge:latest |
with each release | Trying it out. An unattended latest will upgrade you across breaking changes. |
Images carry build provenance and an SBOM, so your scanner can answer "are we exposed to CVE-x" without asking us:
docker buildx imagetools inspect ghcr.io/workos/scim-bridge:latestCutting a release, and the checks the pipeline runs before publishing anything,
are documented in docs/releasing.md.
Issues and pull requests are welcome — see
CONTRIBUTING.md for how to get set up, what the review
looks for, and the one thing we are strict about: break your change on purpose
and confirm the test goes red before you open the PR. A guard that was never
seen to fail is indistinguishable from one that does nothing, and both look the
same in a green build.
Please don't report vulnerabilities in a public issue. Email
security@workos.com — see SECURITY.md, which also documents
what this process is trusted with and the deployment assumptions its threat model
makes.
MIT — see LICENSE.