> Markdown version of https://authpi.com/docs/concepts/multi-org-tokens/ — fetch the complete AuthPI docs index at https://authpi.com/llms.txt to discover all available pages.

# Multi-Org Tokens

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

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](/docs/concepts/organizations/#status-management) 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:

```json
"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](/docs/reference/token-claims/) 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:

```http
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`:

```json
{
  "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 selection | At login, via an `/authorize` parameter | Per request by default; optional `org` parameter for selected-org tokens |
| Switching orgs | New authorization round trip | Client-side state change for multi-org tokens; new authorization when using selected-org tokens |
| Sessions | One per user *per org* | One per user |
| Token contents | One org ID + role | Active memberships allowed by client settings, or one selected org |
| Org access removal | Revoke that org's tokens/session | Membership 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.

```typescript
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](/docs/guides/webhooks/) 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

- [Token claims reference](/docs/reference/token-claims/) — every claim in access and ID tokens, `aud` precedence, and TTL configuration
- [Organizations](/docs/concepts/organizations/) — memberships, invitations, and organization lifecycle
- [OIDC & OAuth 2.0 compliance](/docs/reference/oidc/) — supported grants, PKCE, introspection, and revocation
- [Webhooks](/docs/guides/webhooks/) — react to membership changes in real time