Core Concepts

Multi-Org Tokens

How AuthPI embeds active organization memberships in tokens, how selected-org tokens work, and how your resource server authorizes per org.

Last updated 2026-06-20

If you’re coming from Auth0 Organizations or WorkOS, you may expect every token to be bound to one organization: the user authenticates into an org, the token carries that org’s ID and role, and switching orgs means another round trip through the identity provider.

AuthPI’s default is multi-org: user access tokens and ID tokens embed the user’s active organization memberships. One session serves all tenants a user belongs to, and your API decides—per request—which membership applies.

For clients that need narrower tokens, AuthPI also supports selected-org issuance. Add org=org_... to the authorization request to restrict the issued token set to that organization. Client settings can further restrict organization claims to all orgs, no orgs, or an allowlist.

Token organization claims

When AuthPI issues user access tokens and ID tokens, it loads the user’s active memberships and applies the client’s organization restrictions. Suspended memberships are excluded—only active ones make it into tokens. Memberships of suspended organizations are excluded the same way: suspending an org stops its claims from entering any newly issued token.

The organizations claim has the same array shape in access tokens, ID tokens, and /userinfo responses:

"organizations": [
  {
    "id": "org_0gw3hcq8r2kfn7xj9tzm4be5a",
    "title": "Founder",
    "scopes": ["owner", "billing:write"],
    "joined_at": 1767312000
  },
  {
    "id": "org_0hk2tqvw8m3rfe9pjx5zcn4ba",
    "title": null,
    "scopes": ["member", "projects:read"],
    "joined_at": 1773100800
  }
]

Refresh tokens do not carry full organization membership claims. If the authorization request used org=org_..., the refresh token carries only an org_id marker so refresh can keep issuing selected-org tokens after re-reading current memberships.

See the token claims reference for every claim in each token type.

Selected-org tokens

To ask for a token set restricted to one organization, include org in the authorization request:

GET /authorize?
  response_type=code&
  client_id=c_...&
  scope=openid%20profile&
  org=org_0gw3hcq8r2kfn7xj9tzm4be5a

AuthPI validates the selected organization at token issuance. If the user is not an active member, the authorization-code exchange fails with invalid_grant. If the client is not allowed to request that organization, the request fails with invalid_request.

Selected-org state is bound to the authorization code and resulting session. The /token request cannot add, remove, or change it.

Client organization restrictions

Clients can restrict organization claims under settings.restrictions.organizations:

{
  "settings": {
    "restrictions": {
      "organizations": {
        "policy": "allowlist",
        "allowed_org_ids": ["org_0gw3hcq8r2kfn7xj9tzm4be5a"]
      }
    }
  }
}

Policies:

  • all: include all active memberships unless org selected one.
  • none: issue no organization claims and reject selected-org requests.
  • allowlist: include only active memberships in allowed_org_ids; selected-org requests must name an allowlisted org.

Why multi-org tokens

The single-org-per-token model forces a question at login that users shouldn’t have to answer: which organization are you here for? AuthPI removes that question entirely:

  • No re-login to switch organizations. A consultant in five client orgs gets one session and one set of tokens covering all five. Switching is instant.
  • Org switching is a client-side concern. Your app stores the “current org” in local state or a URL segment (app.example.com/acme/...) and sends it with each request. No identity-provider round trip.
  • One session serves all tenants. Session lifecycle (refresh, revocation, logout) is per user, not per user-per-org. Fewer sessions to manage, fewer edge cases.

This has two deliberate design consequences:

  1. Org selection is optional. Omit org for all active memberships allowed by the client. Include org=org_... when you intentionally want a selected-org token set.
  2. There is no token-exchange grant. The supported grants are exactly authorization_code, refresh_token, and client_credentials. You never trade a multi-org token for a single-org one—the resource server picks the relevant entry instead.

Contrast: single-org vs. multi-org tokens

Single-org-per-token (Auth0/WorkOS model)AuthPI
Org selectionAt login, via an /authorize parameterPer request by default; optional org parameter for selected-org tokens
Switching orgsNew authorization round tripClient-side state change for multi-org tokens; new authorization when using selected-org tokens
SessionsOne per user per orgOne per user
Token contentsOne org ID + roleActive memberships allowed by client settings, or one selected org
Org access removalRevoke that org’s tokens/sessionMembership disappears from tokens at next refresh

Scopes are roles

AuthPI has no separate role system. A membership carries a scopes array, and a scope is any string (1–100 characters). owner, admin, and member are conventions, not reserved words—they sit in the same array as fine-grained scopes like projects:write or billing:read.

Two conventions you’ll see in practice:

  • When an issuer auto-creates an organization at signup, the creator’s membership is granted ["owner", "*:**"]—the owner role convention plus a wildcard.
  • Organizations can configure default member scopes (for example ["member"]) that new members receive on join.

Your API defines what these strings mean. owner grants nothing by itself; it grants whatever your authorization code says it grants.

Authorizing a request in your API

Authorization is two steps: identify which org the request targets (header, path segment, or subdomain—your choice), then find that org in the token and check its scopes.

import { createRemoteJWKSet, jwtVerify } from "jose";

const issuer = process.env.AUTHPI_ISSUER_URL!; // e.g. https://idp.authpi.com/i_8fk2mqzr4tw1ab
const jwks = createRemoteJWKSet(new URL(`${issuer}/jwks.json`));

interface OrgEntry { id: string; scopes: string[] }

export async function authorizeRequest(req: Request, requiredScope: string) {
  const token = req.headers.get("authorization")?.replace(/^Bearer /, "") ?? "";
  const { payload } = await jwtVerify(token, jwks, {
    issuer,
    audience: "https://api.example.com", // the audience your client requests for this API
  });

  // The caller declares which org this request is for
  const orgId = req.headers.get("x-org-id");
  const orgs = (payload.organizations ?? []) as OrgEntry[];
  const membership = orgs.find((o) => o.id === orgId);

  if (!membership) {
    throw new Response("Not a member of this organization", { status: 403 });
  }
  if (!membership.scopes.includes(requiredScope)) {
    throw new Response(`Missing scope: ${requiredScope}`, { status: 403 });
  }

  return { userId: payload.sub as string, orgId, scopes: membership.scopes };
}

If you treat owner or admin as superseding fine-grained scopes, encode that in your check (scopes.includes("owner") || scopes.includes(requiredScope))—AuthPI doesn’t impose a hierarchy.

The membership propagation contract

Since tokens are snapshots, the natural question is: what happens when someone is removed from an org? The contract is:

  • Fresh at every refresh. On each refresh_token grant, AuthPI re-reads the user’s memberships from the index—it does not copy them from the old token. A removed or demoted member gets the corrected claims in the very next access token.
  • Selected orgs stay selected. If the original authorization used org, refresh keeps the same org_id restriction. If that membership disappears, the refreshed token has organizations: [] rather than expanding back to all memberships.
  • Staleness is bounded by the access-token TTL. An already-issued access token keeps its embedded memberships until it expires. With the default TTL of 30 minutes (configurable per client via default_access_token_age), that is the maximum staleness window.
  • No forced logout. Removing a membership does not revoke the user’s refresh token or session. The user stays signed in to your app—they just lose that organization’s entry in their claims at the next refresh. Removal from an org is not removal from the issuer.

For most B2B applications this is the right trade: removal takes effect within minutes, without the operational cost of hunting down sessions. When you need tighter guarantees:

  1. Shorten the access-token TTL for the relevant client (settings.openid.default_access_token_age, in seconds). A 5-minute TTL means a 5-minute worst-case staleness.
  2. React to membership events. Subscribe a webhook to organization.membership.deleted and organization.membership.updated, and maintain a short-lived server-side denylist of (user, org) pairs that your API consults alongside the token.
  3. Check server-side for sensitive operations. For destructive or high-value actions (deleting data, changing billing), verify the membership against the Core API at request time instead of trusting the token snapshot.

Next steps