Skip to main content

Organizations

Organizations are a sub-tenant primitive inside an Auth application × environment. Each organization groups end users into an isolated workspace, and one end-user identity can belong to many organizations within the same environment — the Slack / Linear / Notion model.

Available on Pro and Enterprise plans. Free-tier applications keep today's flat user-pool behavior.

When to use organizations

Use organizations if your product has the concept of a workspace, team, tenant, account, or organization that groups your end users together. The classic shape:

  • B2B SaaS where each customer is an "account" with multiple seats
  • Multi-workspace identity — one login, many workspaces (Slack, Linear, Notion)
  • Self-serve signup where new users land in their own fresh workspace
  • Shared workspaces where new members join by invite

If your product is consumer-facing with a flat user pool — every end user is independent, no shared groups — you do not need organizations. Leave them disabled and tokens carry no org_id (today's behavior, byte-identical).

How organizations fit into the hierarchy

Account                                ← your Zyphr account (billing-bearing)
└─ Project ← optional grouping
└─ Application ← the Auth user pool
└─ Environment (live | test) ← per-key scope (Stripe/Stytch model)
└─ Organizations ← NEW — your customers' workspaces
└─ Members (end_user × organization)

Organizations are scoped to a specific environment — test-mode organizations are distinct objects from live-mode organizations, and a membership ties one end user to one organization within one environment. Membership is many-to-many: the same identity can belong to many orgs in the same environment.

End users themselves are unchanged — end_users.environment_id and the existing (environment_id, email) uniqueness still hold. Organizations live alongside, not on top of.

The Default organization

Every Auth environment gets one auto-created Default organization at migration time, and every existing end user is auto-added to it. The Default org:

  • Cannot be renamed via the API (PATCH returns 403 cannot_modify_default_org)
  • Cannot be deleted via the API (DELETE returns 403 cannot_delete_default_org)
  • Carries an is_default: true flag so your UI can hide or distinguish it

This is the seam that keeps the rollout non-regressive. Applications that never call organizations.create keep operating on the Default org silently — tokens carry no org_id, your existing code reads email and user_id exactly as before. Adopting organizations is opt-in.

JWT claims

When organizations are in use, the end-user access token gains two optional claims:

{
// ...existing claims (user_id, application_id, environment_id, email, type, ...)
"org_id": "01h…", // the active organization for this token (Zyphr-attested)
"orgs": ["01h…", ...] // the user's full membership list in this env (capped at 50)
}
  • org_id is Zyphr-attested. We verify membership at mint time on every login, register, and switchOrganization call. Refresh preserves the value verbatim. You can trust org_id from a verified token directly; you do not need to re-check membership server-side on every request.
  • orgs is capped at 50 ids. If a user belongs to more than 50 organizations in the environment, the orgs claim is omitted entirely and your client should call listOrganizationsForUser to fetch the full list.
  • Both claims are absent when the user only belongs to the Default org. Existing applications that have not adopted organizations see byte-identical tokens to today.

Both claims are reserved — they cannot be shadowed via custom_claims. Attempting to set custom_claims: { org_id: "..." } returns 400 validation_error.

Three flows

1. Self-serve signup → fresh workspace

A new customer signs up. Your backend creates an organization for them, registers the user atomically into that org, and returns a token with org_id already set.

Node.js
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,
});

// 1. Create the customer's workspace
const acme = await zyphr.auth.organizations.createOrganization({
name: 'Acme Inc',
slug: 'acme',
metadata: { signupSource: 'web', plan: 'pro' },
});

// 2. Atomically register the user into the new org
const signup = await zyphr.auth.registration.registerEndUser({
email: 'founder@acme.example.com',
password: 'SecureP@ss123!',
name: 'Acme Founder',
organizationId: acme.data.id,
});

// signup.data.tokens.accessToken now carries org_id = acme.data.id

2. Invite a teammate into the same workspace

You own the invite UX — email match, accept landing page, your own invite tokens. When the invitee accepts, atomically mark the invite accepted in your tables and call addOrganizationMember:

Node.js
// Inside your invite-accept handler:
await zyphr.auth.organizations.addOrganizationMember(acme.data.id, {
userId: invitee.id,
role: 'member', // opaque to Zyphr in v1
});

The role string is opaque to Zyphr in v1 — your application is free to use owner / admin / member or any other scheme. We store it on the membership row and surface it in listMembers, but we do not interpret it. Real RBAC with named roles lands as an Enterprise add-on later.

3. One user in multiple workspaces — workspace switch

The Slack model. The same identity belongs to multiple orgs, and the user picks the active one. When they click "Switch to Globex" in your UI, call switchOrganization with the current refresh token:

Node.js
const switched = await zyphr.auth.organizations.switchOrganization({
refreshToken: currentRefreshToken,
organizationId: globex.id,
});

// switched.data.tokens.accessToken now carries org_id = globex.id
// The old refresh token is dead (jti rotated)

switchOrganization mints a fresh access + refresh pair with org_id set to the new target and rotates the refresh token's jti so the old session is invalidated. Membership is verified at mint time — if the user is not a member of the target org, the call returns 403 not_a_member_of_organization and no token is issued.

Default org_id behavior at login

When loginEndUser is called without an explicit organizationId:

User's non-Default membershipsToken's org_idToken's orgs
0omittedomitted
1set to that org[that org]
2+omitted — caller must switchfull list (or omitted if >50)

The 2+ case is deliberate. We do not implicitly pick an organization on the user's behalf — a token with org_id set is a credential to act inside that org, and an implicit pick can land cross-tenant actions. The orgs claim is still populated so your client can render a workspace switcher and call switchOrganization to make the active org explicit.

If "pick the first membership" is the behavior your product wants, tell us — we are open to adding an application-level default_organization_strategy opt-in flag.

The auto-pick + refresh-preserve trap

The 1-membership row above has a sharp edge worth flagging. The rule is evaluated at mint time and the resulting org_id is preserved verbatim across refresh. If a user with exactly one non-Default org logs in, gets org_id auto-set, and is later added to a second org while their session is still active, the existing refresh token will keep minting access tokens with the original org_id until the refresh token expires. The user can still call switchOrganization to pick the second org explicitly — but they will not get the auto-picked org_id for the new org until they log in again.

If your product can add memberships out-of-band (admin grants, automated provisioning), either:

  • accept the bounded staleness (refresh tokens default to 30 days), or
  • call revokeAllEndUserSessions for the affected user after the new membership is granted, so the next login re-evaluates the auto-pick rule.

Membership lifecycle and revocation timing

The org_id claim is verified at mint time (login, register-with-org, switchOrganization) and is preserved verbatim across refresh. Refresh does not re-check membership against the live database, because the refresh token's jti was already validated at the session layer and re-checking on every refresh would push membership reads onto the auth hot path.

The practical implication for revocation:

  • When removeOrganizationMember is called, existing tokens with org_id set to that organization remain valid until they expire. The access token's TTL is the floor (default 1 hour); the refresh token will also continue to mint new access tokens with the now-stale org_id until it expires.
  • Same for deleteOrganization — cascade rules wipe memberships, but already-issued tokens carry their attested org_id until expiry.
  • If you need immediate revocation across the fleet (the user got fired, you need them out now), call revokeAllEndUserSessions after removeOrganizationMember or deleteOrganization. That invalidates the refresh tokens and forces a fresh login (with a fresh membership check) on the user's next request.

We deliberately do not run membership re-checks on every refresh because the latency cost would land on every authenticated request, not just the explicit auth-flow endpoints. The trade-off is a bounded staleness window between the access-token TTL and the next revokeAllEndUserSessions call. If you need a tighter window, lower the application's session duration in the dashboard.

There is one additional small window worth documenting: in switchOrganization and loginEndUser, the membership check (SELECT FROM end_user_organization_members ...) and the token mint are not in a single Postgres transaction. A concurrent removeOrganizationMember can land between the check and the mint, producing a token with an org_id for an org the user was just removed from. The window is in the millisecond range and the same revokeAllEndUserSessions escape hatch closes it after the fact.

Cursor pagination

listOrganizations and listOrganizationMembers return cursor-paginated results:

Node.js
let cursor: string | undefined;
const all = [];
do {
const page = await zyphr.auth.organizations.listOrganizations(
50, // limit (1–200, default 50)
cursor, // cursor from the previous page
);
all.push(...page.data);
cursor = page.meta.nextCursor ?? undefined;
} while (cursor);

Cursors are opaque, time-ordered, and stable across writes. Pass them back verbatim; do not parse them.

Free-tier behavior

On the Free plan, organizations are not enabled. The dashboard shows the Organizations tab grayed-out with an upgrade prompt, the API returns 402 organizations_not_enabled on createOrganization and addOrganizationMember, and tokens never carry org_id. Every environment still has its implicit Default org under the hood — it is invisible to your code.

On Pro / Enterprise, organizations are fully enabled with no per-org or per-member quotas in v1.

API reference

The full route surface is documented in the API Reference:

  • POST /v1/auth/organizations — create
  • GET /v1/auth/organizations — list
  • GET /v1/auth/organizations/:id — get
  • PATCH /v1/auth/organizations/:id — update (Default-org guarded)
  • DELETE /v1/auth/organizations/:id — delete (Default-org guarded)
  • POST /v1/auth/organizations/:id/members — add member
  • GET /v1/auth/organizations/:id/members — list members
  • PATCH /v1/auth/organizations/:id/members/:user_id — update role
  • DELETE /v1/auth/organizations/:id/members/:user_id — remove member
  • GET /v1/auth/end-users/:user_id/organizations — list orgs for a user
  • POST /v1/auth/sessions/switch-organization — switch active org

All routes require application service credentials (X-Application-Key + X-Application-Secret). The dashboard surface for the same operations is at /v1/auth/dashboard/applications/:applicationId/organizations (dashboard JWT auth, intended for the Zyphr Dashboard itself).

Migrating from a flat-app integration

If you already have a flat-app Zyphr integration (no organizations) and you want to adopt them, see the Organizations Migration Guide — it walks the cutover step-by-step, including the Sprint-specific case of migrating from custom_claims.workspace_id as an interim carrier.