Adopting Organizations in an Existing Integration
This guide walks through enabling Organizations on an Auth application that has been running as a flat user pool. The migration is additive — no re-auth event for end users, no schema break on your side, no required code changes for callers who do not opt in.
TL;DR
| You currently have | After this migration |
|---|---|
| One Auth application, flat user pool | Same application, organizations layered on top |
end_users keyed by (env_id, email) | Unchanged — end_users table is not touched |
Tokens without org_id | Tokens that may carry org_id once memberships exist |
Custom workspace_id in custom_claims (Sprint pattern) | Zyphr-attested org_id; custom claim can be dropped |
Server-side workspace_members re-check on every request | Can be deleted — trust org_id from the verified token |
Step 0 — Confirm your plan
Organizations are gated behind Pro and Enterprise. On the Free plan, createOrganization returns 402 organizations_not_enabled and the dashboard tab is grayed-out. Upgrade before starting.
Step 1 — Migrate without changing any code
When the V172 migration applies (already shipped), the following happens automatically:
- Every
application_environmentgets one Default organization (one for live, one for test). - Every end user with a non-
NULLenvironment_idis added to the Default org of their environment. - Pre-V021 legacy users with
NULLenvironment_id are skipped.
Nothing in your code needs to change at this step. Tokens minted for any user that only belongs to the Default org continue to carry no org_id claim — byte-identical to today's wire format. Your existing login, register, refresh, and downstream consumers are unaffected.
Verify this by hitting any of your routes with an existing user and confirming the JWT payload has the same key set as before the migration. If it does, you are safely on the new schema with zero behavioral changes.
Step 2 — Plan your org model
Decide what an "organization" means in your product. Common shapes:
- One org per customer account — B2B SaaS where each paying account is one workspace
- One org per workspace — Slack / Linear / Notion model where one identity belongs to many workspaces
- One org per team — granular grouping inside a single customer account
You can always start with one org per customer and add finer-grained orgs later — organizations are mutable and additive.
Map your existing model:
| Your concept | Zyphr concept |
|---|---|
Your workspace_id / account_id / team_id | organizations.id |
Your workspace_members rows | end_user_organization_members rows |
Your role enum on memberships | role opaque string on memberships |
| Your "active workspace" UI state | org_id claim on the access token |
Step 3 — Create one organization per existing workspace
Call createOrganization for every workspace/account/team in your system. The recommended pattern is to stash your own id in the metadata so you can correlate during the cutover:
import { Zyphr } from '@zyphr-dev/node-sdk';
const zyphr = new Zyphr({
apiKey: process.env.ZYPHR_API_KEY!,
applicationKey: process.env.ZYPHR_APP_KEY!,
applicationSecret: process.env.ZYPHR_APP_SECRET!,
});
// Pull your existing workspaces. Run this once per environment (live, test).
const workspaces = await yourDb.query('SELECT id, name, slug FROM workspaces');
for (const ws of workspaces) {
const org = await zyphr.auth.organizations.createOrganization({
name: ws.name,
slug: ws.slug,
metadata: { yourWorkspaceId: ws.id },
});
// Stash the Zyphr org id back on your row so future writes know the mapping
await yourDb.query(
'UPDATE workspaces SET zyphr_org_id = $1 WHERE id = $2',
[org.data.id, ws.id]
);
}
Run this against your test environment first, then live. The script is idempotent against re-runs as long as you guard on zyphr_org_id IS NULL.
Step 4 — Migrate memberships
Walk your existing membership table and call addOrganizationMember for each row:
const memberships = await yourDb.query(`
SELECT
w.zyphr_org_id,
eu.zyphr_user_id,
wm.role
FROM workspace_members wm
JOIN workspaces w ON w.id = wm.workspace_id
JOIN end_users eu ON eu.id = wm.user_id
WHERE w.zyphr_org_id IS NOT NULL
AND eu.zyphr_user_id IS NOT NULL
`);
for (const m of memberships) {
try {
await zyphr.auth.organizations.addOrganizationMember(m.zyphr_org_id, {
userId: m.zyphr_user_id,
role: m.role, // your owner/admin/member enum — opaque to Zyphr
});
} catch (err) {
// 409 already_a_member is fine on re-run
if (err.code !== 'already_a_member') throw err;
}
}
Step 5 — Flip the cutover one flow at a time
You do not need to flip everything at once. Migrate one auth flow at a time:
Login
Replace your existing login call:
// Before
const login = await zyphr.auth.login.loginEndUser({ email, password });
// After
const login = await zyphr.auth.login.loginEndUser({
email,
password,
organizationId: activeOrgIdFromYourState, // optional — see "Default org_id behavior"
});
If you do not pass organizationId and the user belongs to exactly one org, Zyphr auto-populates org_id on the resulting token. If they belong to multiple, org_id is omitted and your client must call switchOrganization to pick.
Register
// Atomic: create user + add to org + mint with org_id set
const signup = await zyphr.auth.registration.registerEndUser({
email,
password,
organizationId: newOrgId,
});
Workspace switch
Replace your existing "switch workspace" endpoint:
// Before (Sprint interim pattern with custom claims)
const refreshed = await zyphr.auth.sessions.refreshEndUserToken({
refreshToken,
customClaims: { workspace_id: targetWorkspaceId },
});
// After
const switched = await zyphr.auth.organizations.switchOrganization({
refreshToken,
organizationId: targetOrgId,
});
Token consumer
Replace your server-side membership re-check with reading org_id from the verified token:
// Before
const claims = verifyToken(req.headers.authorization);
const workspaceId = claims.custom_claims?.workspace_id;
const isMember = await yourDb.query(
'SELECT 1 FROM workspace_members WHERE user_id = $1 AND workspace_id = $2',
[claims.user_id, workspaceId]
);
if (!isMember.rows.length) throw new Error('not a member');
// After
const claims = verifyToken(req.headers.authorization);
const orgId = claims.org_id;
// Trust org_id — Zyphr verified membership at mint time
org_id on a Zyphr-issued token is attested at mint time, so the database re-check goes away. See the trust model for the full reasoning.
Sprint-specific cutover (custom_claims → org_id)
If you adopted the interim custom_claims.sprint_workspace_id pattern, the cutover is one line per flow:
| Flow | Before | After |
|---|---|---|
| Login | customClaims: { sprint_workspace_id: id } | organizationId: id |
| Switch | refreshEndUserToken({ refreshToken, customClaims: { sprint_workspace_id: id } }) | switchOrganization({ refreshToken, organizationId: id }) |
| Read | claims.custom_claims.sprint_workspace_id | claims.org_id |
Both forms can coexist during the rollout — Zyphr continues to honor custom_claims.sprint_workspace_id until you stop sending it. Strip the custom claim after your token consumers have switched to reading org_id.
The custom claim was never Zyphr-attested (we held it faithfully but did not verify it). org_id is attested. You can simultaneously delete your workspace_members server-side re-check, because the trust boundary moves to mint time.
Step 6 — Clean up
Once your application reads org_id everywhere it previously read your local workspace_id:
- Drop the
workspace_memberstable on your side (or keep it as a denormalized cache if you need fast bulk queries) - Stop sending
custom_claims.sprint_workspace_idon new auth requests - Remove your server-side membership re-check middleware
Rollback
If you need to roll back during the migration window:
- Step 1–4 are safe to leave in place. Memberships in Zyphr do nothing on their own; tokens only carry
org_idif you passorganizationIdat login or the user has exactly one non-Default membership. - Step 5 rollback: stop passing
organizationIdat login. Your tokens go back to carrying your customworkspace_id(or nothing, depending on your prior state). Theorg_idclaim disappears from new tokens; existing tokens still carry whatever they were minted with.
The migration is one-way on your side (your code stops sending the custom claim), but Zyphr-side state can be kept indefinitely without affecting flat-app behavior.
Need help?
- Detailed concept reference: Organizations feature page
- API surface: API Reference
- Stuck? Contact support and reference epic-5587.