Skip to main content

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 haveAfter this migration
One Auth application, flat user poolSame application, organizations layered on top
end_users keyed by (env_id, email)Unchanged — end_users table is not touched
Tokens without org_idTokens 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 requestCan 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_environment gets one Default organization (one for live, one for test).
  • Every end user with a non-NULL environment_id is added to the Default org of their environment.
  • Pre-V021 legacy users with NULL environment_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 conceptZyphr concept
Your workspace_id / account_id / team_idorganizations.id
Your workspace_members rowsend_user_organization_members rows
Your role enum on membershipsrole opaque string on memberships
Your "active workspace" UI stateorg_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:

Node.js — backfill script
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:

Node.js — membership backfill
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:

FlowBeforeAfter
LogincustomClaims: { sprint_workspace_id: id }organizationId: id
SwitchrefreshEndUserToken({ refreshToken, customClaims: { sprint_workspace_id: id } })switchOrganization({ refreshToken, organizationId: id })
Readclaims.custom_claims.sprint_workspace_idclaims.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_members table on your side (or keep it as a denormalized cache if you need fast bulk queries)
  • Stop sending custom_claims.sprint_workspace_id on 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_id if you pass organizationId at login or the user has exactly one non-Default membership.
  • Step 5 rollback: stop passing organizationId at login. Your tokens go back to carrying your custom workspace_id (or nothing, depending on your prior state). The org_id claim 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?